├── .github ├── FUNDING.yml └── workflows │ └── lint.yml ├── .gitignore ├── .swiftformat ├── .swiftlint.yml ├── CONTRIBUTING.md ├── Config.xcconfig ├── DatWeatherDoe.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── DatWeatherDoe.xcscheme ├── DatWeatherDoe ├── API │ ├── NetworkClient.swift │ ├── Repository │ │ └── Zip Code │ │ │ ├── ZipCodeValidator.swift │ │ │ ├── ZipCodeWeatherRepository.swift │ │ │ └── ZipCodeWeatherURLBuilder.swift │ ├── Response │ │ ├── AirQuality.swift │ │ ├── ForecastData.swift │ │ ├── ForecastTemperatureData.swift │ │ ├── SunriseSunsetData.swift │ │ ├── TemperatureData.swift │ │ ├── WeatherAPIResponse.swift │ │ ├── WeatherAPIResponseParser.swift │ │ └── WindData.swift │ ├── WeatherData.swift │ └── WeatherError.swift ├── Config │ ├── APIKeyParser.swift │ ├── ConfigManager.swift │ └── ConfigOptions.swift ├── DatWeatherDoeApp.swift ├── Localization │ └── en.xcloc │ │ ├── Localized Contents │ │ └── en.xliff │ │ ├── Source Contents │ │ └── DatWeatherDoe │ │ │ ├── Resources │ │ │ └── en.lproj │ │ │ │ └── InfoPlist.strings │ │ │ └── UI │ │ │ └── Base.lproj │ │ │ └── MainMenu.xib │ │ └── contents.json ├── Reachability │ └── WeatherReachability.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ ├── 128.png │ │ │ ├── 16.png │ │ │ ├── 256-1.png │ │ │ ├── 256.png │ │ │ ├── 32-1.png │ │ │ ├── 32.png │ │ │ ├── 512-1.png │ │ │ ├── 512.png │ │ │ ├── 64.png │ │ │ └── Contents.json │ │ └── Contents.json │ ├── DatWeatherDoe.entitlements │ ├── DevelopmentAssets │ │ └── TestData.swift │ ├── Info.plist │ └── Localization │ │ └── Localizable.xcstrings ├── UI │ ├── Configure │ │ ├── ConfigureOptionsView.swift │ │ ├── ConfigureUnitOptionsView.swift │ │ ├── ConfigureValueSeparatorOptionsView.swift │ │ ├── ConfigureView.swift │ │ ├── ConfigureViewModel.swift │ │ ├── ConfigureWeatherOptionsView.swift │ │ └── Options │ │ │ ├── MeasurementUnit.swift │ │ │ ├── RefreshInterval.swift │ │ │ ├── TemperatureUnit.swift │ │ │ ├── WeatherConditionPosition.swift │ │ │ └── WeatherSource.swift │ ├── Decorator │ │ ├── Condition │ │ │ ├── WeatherCondition.swift │ │ │ ├── WeatherConditionBuilder.swift │ │ │ └── WeatherConditionTextMapper.swift │ │ ├── Text │ │ │ ├── HumidityTextBuilder.swift │ │ │ ├── SunriseAndSunsetTextBuilder.swift │ │ │ ├── Temperature │ │ │ │ ├── TemperatureForecastTextBuilder.swift │ │ │ │ ├── TemperatureFormatter.swift │ │ │ │ ├── TemperatureTextBuilder.swift │ │ │ │ └── TemperatureWithDegreesCreator.swift │ │ │ ├── UVIndexTextBuilder.swift │ │ │ └── WeatherTextBuilder.swift │ │ └── WeatherDataBuilder.swift │ ├── Forecaster │ │ └── WeatherForecaster.swift │ ├── Menu Bar │ │ ├── CustomButton.swift │ │ ├── DropdownIcon.swift │ │ ├── MenuOptionsView.swift │ │ ├── MenuView.swift │ │ ├── NonInteractiveMenuOptionView.swift │ │ └── WindSpeedFormatter.swift │ └── Status Bar │ │ └── StatusBarView.swift └── ViewModel │ ├── Parser │ ├── CityWeatherResultParser.swift │ └── ZipCodeWeatherResultParser.swift │ ├── Repository │ ├── Coordinates │ │ ├── LocationCoordinatesWeatherRepository.swift │ │ ├── LocationParser.swift │ │ └── LocationValidator.swift │ ├── System │ │ ├── SystemLocationFetcher.swift │ │ └── SystemLocationWeatherRepository.swift │ ├── WeatherRepositoryFactory.swift │ ├── WeatherRepositoryType.swift │ ├── WeatherURLBuilder.swift │ └── WeatherValidatorType.swift │ ├── WeatherDataFormatter.swift │ ├── WeatherViewModel.swift │ └── WeatherViewModelType.swift ├── DatWeatherDoeTests ├── API │ └── Repository │ │ ├── Location │ │ └── Coordinates │ │ │ └── LocationValidatorTests.swift │ │ └── WeatherURLBuilderTests.swift ├── Config │ └── ConfigManagerTests.swift ├── DatWeatherDoe.xctestplan └── UI │ └── Configure │ └── Options │ ├── RefreshIntervalTests.swift │ ├── TemperatureUnitTests.swift │ └── WeatherSourceTests.swift ├── LICENSE ├── README.md ├── location_services_1.png ├── location_services_2.png ├── logo.png ├── screenshot_1.png └── screenshot_2.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: inderdhir 4 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/swiftlint.yml' 7 | - '.swiftlint.yml' 8 | - '**/*.swift' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: GitHub Action for SwiftLint (Different working directory) 16 | uses: norio-nomura/action-swiftlint@3.2.1 17 | env: 18 | WORKING_DIRECTORY: Source -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | .DS_Store 3 | 4 | # Backup files 5 | *~ 6 | 7 | # JetBrains 8 | .idea/ 9 | 10 | ## Build generated 11 | build/ 12 | DerivedData 13 | 14 | ## Various settings 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | 25 | ## Other 26 | *.xccheckout 27 | *.moved-aside 28 | *.xcuserstate 29 | *.xcscmblueprint 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | *.ipa 34 | 35 | # Thumbnails 36 | ._* 37 | 38 | # Files that might appear in the root of a volume 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | 46 | # Directories potentially created on remote AFP share 47 | .AppleDB 48 | .AppleDesktop 49 | Network Trash Folder 50 | Temporary Items 51 | .apdisk 52 | The above adds CocoaPods, 53 | 54 | Pods/ 55 | Podfile.lock 56 | 57 | # Keys 58 | DatWeatherDoe/Resources/Keys.plist 59 | 60 | # Secrets 61 | Config.xcconfig 62 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --disable trailingCommas -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - colon 3 | - comma 4 | - control_statement 5 | - trailing_whitespace 6 | - vertical_parameter_alignment 7 | - opening_brace 8 | opt_in_rules: # some rules are only opt-in 9 | - empty_count 10 | # Find all the available rules by running: 11 | # swiftlint rules 12 | included: # paths to include during linting. `--path` is ignored if present. 13 | - DatWeatherDoe 14 | excluded: # paths to ignore during linting. Takes precedence over `included`. 15 | - Pods 16 | # configurable rules can be customized from this configuration file 17 | # configurable rules can be customized from this configuration file 18 | # binary rules can set their severity level 19 | cyclomatic_complexity: 20 | ignores_case_statements: true 21 | force_cast: warning # implicitly 22 | force_try: 23 | severity: warning # explicitly 24 | # rules that have both warning and error levels, can set just the warning level 25 | # implicitly 26 | line_length: 110 27 | # they can set both implicitly with an array 28 | type_body_length: 29 | - 300 # warning 30 | - 400 # error 31 | # or they can set both explicitly 32 | file_length: 33 | warning: 500 34 | error: 1200 35 | # naming rules can set warnings/errors for min_length and max_length 36 | # additionally they can set excluded names 37 | type_name: 38 | min_length: 4 # only warning 39 | max_length: # warning and error 40 | warning: 40 41 | error: 50 42 | excluded: iPhone # excluded via string 43 | identifier_name: 44 | min_length: # only min_length 45 | error: 2 # only error 46 | excluded: # excluded via string array 47 | - id 48 | - URL 49 | - GlobalAPIKey 50 | function_body_length: 51 | warning: 50 52 | error: 100 53 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji) 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions / pull requests are welcome! 4 | 5 | Please note that the goal of this project to provide a lightweight menu bar app for weather at a glance on macOS (similar to weather app indicators on Ubuntu). 6 | 7 | **MacOS 11 provides a weather widget that can be used in conjunction with this app so a macOS widget is NOT on the roadmap.** -------------------------------------------------------------------------------- /Config.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Config.xcconfig 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 2/12/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | // Configuration settings file format documentation can be found at: 10 | // https://help.apple.com/xcode/#/dev745c5c974 11 | 12 | WEATHER_API_KEY= 13 | -------------------------------------------------------------------------------- /DatWeatherDoe.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DatWeatherDoe.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DatWeatherDoe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "cf72def9a17d22a974c73e8b0f0a4d80bccc1e736e0ce0e89a85e671a37bb5b6", 3 | "pins" : [ 4 | { 5 | "identity" : "collectionconcurrencykit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", 8 | "state" : { 9 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", 10 | "version" : "0.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "cryptoswift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 17 | "state" : { 18 | "revision" : "678d442c6f7828def400a70ae15968aef67ef52d", 19 | "version" : "1.8.3" 20 | } 21 | }, 22 | { 23 | "identity" : "menubarextraaccess", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/orchetect/MenuBarExtraAccess", 26 | "state" : { 27 | "revision" : "9ff6cbb0ba373527a19832a0791cea4a41ad7a6f", 28 | "version" : "1.1.3" 29 | } 30 | }, 31 | { 32 | "identity" : "reachability.swift", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/ashleymills/Reachability.swift", 35 | "state" : { 36 | "revision" : "21d1dc412cfecbe6e34f1f4c4eb88d3f912654a6", 37 | "version" : "5.2.4" 38 | } 39 | }, 40 | { 41 | "identity" : "sourcekitten", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/jpsim/SourceKitten.git", 44 | "state" : { 45 | "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", 46 | "version" : "0.35.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-argument-parser", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-argument-parser.git", 53 | "state" : { 54 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 55 | "version" : "1.5.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-syntax", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/swiftlang/swift-syntax.git", 62 | "state" : { 63 | "revision" : "515f79b522918f83483068d99c68daeb5116342d", 64 | "version" : "600.0.0-prerelease-2024-08-14" 65 | } 66 | }, 67 | { 68 | "identity" : "swiftformat", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/nicklockwood/SwiftFormat", 71 | "state" : { 72 | "revision" : "86ed20990585f478c0daf309af645c2a528b59d8", 73 | "version" : "0.54.6" 74 | } 75 | }, 76 | { 77 | "identity" : "swiftlint", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/realm/SwiftLint", 80 | "state" : { 81 | "revision" : "168fb98ed1f3e343d703ecceaf518b6cf565207b", 82 | "version" : "0.57.0" 83 | } 84 | }, 85 | { 86 | "identity" : "swiftytexttable", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", 89 | "state" : { 90 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 91 | "version" : "0.9.0" 92 | } 93 | }, 94 | { 95 | "identity" : "swxmlhash", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/drmohundro/SWXMLHash.git", 98 | "state" : { 99 | "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", 100 | "version" : "7.0.2" 101 | } 102 | }, 103 | { 104 | "identity" : "yams", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/jpsim/Yams.git", 107 | "state" : { 108 | "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", 109 | "version" : "5.1.3" 110 | } 111 | } 112 | ], 113 | "version" : 3 114 | } 115 | -------------------------------------------------------------------------------- /DatWeatherDoe.xcodeproj/xcshareddata/xcschemes/DatWeatherDoe.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 47 | 49 | 55 | 56 | 57 | 58 | 64 | 66 | 72 | 73 | 74 | 75 | 77 | 78 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/NetworkClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkClient.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/14/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol NetworkClientType { 12 | func performRequest(url: URL) async throws -> Data 13 | } 14 | 15 | final actor NetworkClient: NetworkClientType { 16 | func performRequest(url: URL) async throws -> Data { 17 | do { 18 | return try await URLSession.shared.data(from: url).0 19 | } catch { 20 | throw WeatherError.networkError 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Repository/Zip Code/ZipCodeValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZipCodeValidator.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/13/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | final class ZipCodeValidator: WeatherValidatorType { 10 | private let zipCode: String 11 | 12 | init(zipCode: String) { 13 | self.zipCode = zipCode 14 | } 15 | 16 | func validate() throws { 17 | let isZipPresent = !zipCode.isEmpty 18 | let isZipPresentWithCountryCode = zipCode.split(separator: ",").count == 2 19 | let isValid = isZipPresent && isZipPresentWithCountryCode 20 | if !isValid { 21 | throw WeatherError.zipCodeIncorrect 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Repository/Zip Code/ZipCodeWeatherRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZipCodeWeatherRepository.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/14/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class ZipCodeWeatherRepository: WeatherRepositoryType { 12 | private let appId: String 13 | private let zipCode: String 14 | private let networkClient: NetworkClient 15 | private let logger: DatWeatherDoeLoggerType 16 | 17 | init( 18 | appId: String, 19 | zipCode: String, 20 | networkClient: NetworkClient, 21 | logger: DatWeatherDoeLoggerType 22 | ) { 23 | self.appId = appId 24 | self.zipCode = zipCode 25 | self.networkClient = networkClient 26 | self.logger = logger 27 | } 28 | 29 | func getWeather(completion: @escaping (Result) -> Void) { 30 | logger.debug("Getting weather via zip code") 31 | 32 | do { 33 | try validateZipCode() 34 | let url = try buildURL() 35 | performRequest(url: url, completion: { [weak self] result in 36 | self?.parseNetworkResult(result: result, completion: completion) 37 | }) 38 | } catch { 39 | logger.error("Getting weather via zip code failed. Zip code is incorrect!") 40 | 41 | completion(.failure(error)) 42 | } 43 | } 44 | 45 | private func validateZipCode() throws { 46 | try ZipCodeValidator(zipCode: zipCode).validate() 47 | } 48 | 49 | private func buildURL() throws -> URL { 50 | try ZipCodeWeatherURLBuilder(appId: appId, zipCode: zipCode).build() 51 | } 52 | 53 | private func performRequest(url: URL, completion: @escaping (Result) -> Void) { 54 | networkClient.performRequest(url: url, completion: completion) 55 | } 56 | 57 | private func parseNetworkResult( 58 | result: Result, 59 | completion: @escaping (Result) -> Void 60 | ) { 61 | switch result { 62 | case let .success(data): 63 | do { 64 | let weatherData = try parseWeatherData(data) 65 | completion(.success(weatherData)) 66 | } catch { 67 | let weatherError = (error as? WeatherError) ?? WeatherError.other 68 | completion(.failure(weatherError)) 69 | } 70 | case let .failure(error): 71 | let weatherError = (error as? WeatherError) ?? WeatherError.other 72 | completion(.failure(weatherError)) 73 | } 74 | } 75 | 76 | private func parseWeatherData(_ data: Data) throws -> WeatherAPIResponse { 77 | try WeatherAPIResponseParser().parse(data) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Repository/Zip Code/ZipCodeWeatherURLBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZipCodeWeatherURLBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/16/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class ZipCodeWeatherURLBuilder: WeatherURLBuilder { 12 | private let zipCode: String 13 | 14 | init(appId: String, zipCode: String) { 15 | self.zipCode = zipCode 16 | super.init(appId: appId) 17 | } 18 | 19 | override func build() throws -> URL { 20 | let queryItems: [URLQueryItem] = [ 21 | URLQueryItem(name: "appid", value: appId), 22 | URLQueryItem(name: "zip", value: zipCode), 23 | ] 24 | 25 | var urlComps = URLComponents(string: apiUrlString) 26 | urlComps?.queryItems = queryItems 27 | guard let finalUrl = urlComps?.url else { 28 | throw WeatherError.unableToConstructUrl 29 | } 30 | return finalUrl 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/AirQuality.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AirQuality.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 7/1/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // US - EPA standard 12 | enum AirQualityIndex: Int, Decodable { 13 | case good = 1 14 | case moderate = 2 15 | case unhealthyForSensitive = 3 16 | case unhealthy = 4 17 | case veryUnhealthy = 5 18 | case hazardous = 6 19 | 20 | var description: String { 21 | switch self { 22 | case .good: 23 | NSLocalizedString( 24 | "Good", 25 | comment: "Air quality index: Good" 26 | ) 27 | case .moderate: 28 | NSLocalizedString( 29 | "Moderate", 30 | comment: "Air quality index: Moderate" 31 | ) 32 | case .unhealthyForSensitive: 33 | NSLocalizedString( 34 | "Unhealthy for sensitive groups", 35 | comment: "Air quality index: Unhealthy for sensitive groups" 36 | ) 37 | case .unhealthy: 38 | NSLocalizedString( 39 | "Unhealthy", 40 | comment: "Air quality index: Unhealthy" 41 | ) 42 | case .veryUnhealthy: 43 | NSLocalizedString( 44 | "Very unhealthy", 45 | comment: "Air quality index: Very unhealthy" 46 | ) 47 | case .hazardous: 48 | NSLocalizedString( 49 | "Hazardous", 50 | comment: "Air quality index: Hazardous" 51 | ) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/ForecastData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForecastData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/23/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Forecast: Decodable { 12 | let dayDataArr: [ForecastDayData] 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case dayDataArr = "forecastday" 16 | } 17 | } 18 | 19 | struct ForecastDayData: Decodable { 20 | let temp: ForecastTemperatureData 21 | let astro: SunriseSunsetData 22 | let hour: [HourlyUVIndex] 23 | 24 | private enum CodingKeys: String, CodingKey { 25 | case temp = "day" 26 | case astro 27 | case hour 28 | } 29 | } 30 | 31 | struct HourlyUVIndex: Decodable { 32 | let uv: Double 33 | } 34 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/ForecastTemperatureData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForecastTemperatureData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/23/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ForecastTemperatureData: Decodable { 12 | let maxTempC: Double 13 | let maxTempF: Double 14 | let minTempC: Double 15 | let minTempF: Double 16 | 17 | private enum CodingKeys: String, CodingKey { 18 | case maxTempC = "maxtemp_c" 19 | case maxTempF = "maxtemp_f" 20 | case minTempC = "mintemp_c" 21 | case minTempF = "mintemp_f" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/SunriseSunsetData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SunriseSunsetData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/22/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct SunriseSunsetData: Decodable { 12 | let sunrise: String 13 | let sunset: String 14 | 15 | private enum CodingKeys: String, CodingKey { 16 | case sunrise, sunset 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/TemperatureData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/23/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct TemperatureData: Decodable { 12 | let tempCelsius: Double 13 | let feelsLikeTempCelsius: Double 14 | let tempFahrenheit: Double 15 | let feelsLikeTempFahrenheit: Double 16 | 17 | private enum CodingKeys: String, CodingKey { 18 | case tempCelsius = "temp_c" 19 | case feelsLikeTempCelsius = "feelslike_c" 20 | case tempFahrenheit = "temp_f" 21 | case feelsLikeTempFahrenheit = "feelslike_f" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/WeatherAPIResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherAPIResponse.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 2/3/18. 6 | // Copyright © 2018 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct WeatherAPIResponse: Decodable { 12 | let locationName: String 13 | let temperatureData: TemperatureData 14 | let isDay: Bool 15 | let weatherConditionCode: Int 16 | let humidity: Int 17 | let windData: WindData 18 | let uvIndex: Double 19 | let forecastDayData: ForecastDayData 20 | let airQualityIndex: AirQualityIndex 21 | 22 | private enum RootKeys: String, CodingKey { 23 | case location, current, forecast 24 | } 25 | 26 | private enum LocationKeys: String, CodingKey { 27 | case name 28 | } 29 | 30 | private enum CurrentKeys: String, CodingKey { 31 | case isDay = "is_day" 32 | case condition, humidity 33 | case airQuality = "air_quality" 34 | case uvIndex = "uv" 35 | } 36 | 37 | private enum WeatherConditionKeys: String, CodingKey { 38 | case code 39 | } 40 | 41 | private enum ForecastKeys: String, CodingKey { 42 | case forecastDay = "forecastday" 43 | } 44 | 45 | private enum ForecastDayKeys: String, CodingKey { 46 | case day, astro 47 | } 48 | 49 | private enum AirQualityKeys: String, CodingKey { 50 | case usEpaIndex = "us-epa-index" 51 | } 52 | 53 | init(from decoder: Decoder) throws { 54 | let container = try decoder.container(keyedBy: RootKeys.self) 55 | 56 | let locationContainer = try container.nestedContainer(keyedBy: LocationKeys.self, forKey: .location) 57 | locationName = try locationContainer.decode(String.self, forKey: .name) 58 | temperatureData = try container.decode(TemperatureData.self, forKey: .current) 59 | 60 | let currentContainer = try container.nestedContainer(keyedBy: CurrentKeys.self, forKey: .current) 61 | let isDayInt = try currentContainer.decode(Int.self, forKey: .isDay) 62 | isDay = isDayInt > 0 63 | 64 | let weatherConditionContainer = try currentContainer.nestedContainer( 65 | keyedBy: WeatherConditionKeys.self, 66 | forKey: .condition 67 | ) 68 | weatherConditionCode = try weatherConditionContainer.decode(Int.self, forKey: .code) 69 | 70 | humidity = try currentContainer.decode(Int.self, forKey: .humidity) 71 | 72 | windData = try container.decode(WindData.self, forKey: .current) 73 | 74 | uvIndex = try currentContainer.decode(Double.self, forKey: .uvIndex) 75 | 76 | let forecast = try container.decode(Forecast.self, forKey: .forecast) 77 | if let dayData = forecast.dayDataArr.first { 78 | forecastDayData = dayData 79 | } else { 80 | throw DecodingError.dataCorruptedError( 81 | forKey: .forecast, 82 | in: container, 83 | debugDescription: "Missing forecast day data" 84 | ) 85 | } 86 | 87 | let airQualityContainer = try currentContainer.nestedContainer(keyedBy: AirQualityKeys.self, forKey: .airQuality) 88 | airQualityIndex = try airQualityContainer.decode(AirQualityIndex.self, forKey: .usEpaIndex) 89 | } 90 | 91 | init( 92 | locationName: String, 93 | temperatureData: TemperatureData, 94 | isDay: Bool, 95 | weatherConditionCode: Int, 96 | humidity: Int, 97 | windData: WindData, 98 | uvIndex: Double, 99 | forecastDayData: ForecastDayData, 100 | airQualityIndex: AirQualityIndex 101 | ) { 102 | self.locationName = locationName 103 | self.temperatureData = temperatureData 104 | self.isDay = isDay 105 | self.weatherConditionCode = weatherConditionCode 106 | self.humidity = humidity 107 | self.windData = windData 108 | self.uvIndex = uvIndex 109 | self.forecastDayData = forecastDayData 110 | self.airQualityIndex = airQualityIndex 111 | } 112 | 113 | // hour = [0-23] 114 | func getHourlyUVIndex(hour: Int) -> Double { 115 | forecastDayData.hour[safe: hour]?.uv ?? uvIndex 116 | } 117 | } 118 | 119 | private extension Array { 120 | subscript(safe index: Index) -> Element? { indices ~= index ? self[index] : nil } 121 | } 122 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/WeatherAPIResponseParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherAPIResponseParser.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/10/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol WeatherAPIResponseParserType { 12 | func parse(_ data: Data) throws -> WeatherAPIResponse 13 | } 14 | 15 | final class WeatherAPIResponseParser: WeatherAPIResponseParserType { 16 | func parse(_ data: Data) throws -> WeatherAPIResponse { 17 | try JSONDecoder().decode(WeatherAPIResponse.self, from: data) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/Response/WindData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/23/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct WindData: Decodable { 12 | let speedMph: Double 13 | let degrees: Int 14 | let direction: String 15 | 16 | private enum CodingKeys: String, CodingKey { 17 | case speedMph = "wind_mph" 18 | case degrees = "wind_degree" 19 | case direction = "wind_dir" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/WeatherData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 5/22/21. 6 | // Copyright © 2021 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct WeatherData { 12 | let showWeatherIcon: Bool 13 | let textualRepresentation: String? 14 | let weatherCondition: WeatherCondition 15 | let response: WeatherAPIResponse 16 | } 17 | -------------------------------------------------------------------------------- /DatWeatherDoe/API/WeatherError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherError.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 5/22/21. 6 | // Copyright © 2021 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum WeatherError: LocalizedError { 12 | case unableToConstructUrl 13 | case locationError 14 | case latLongIncorrect 15 | case networkError 16 | 17 | var errorDescription: String? { 18 | switch self { 19 | case .unableToConstructUrl: 20 | return "Unable to construct URL" 21 | case .locationError: 22 | return NSLocalizedString("❗️Location", comment: "Location error when fetching weather") 23 | case .latLongIncorrect: 24 | return NSLocalizedString("❗️Lat/Long", comment: "Lat/Long error when fetching weather") 25 | case .networkError: 26 | return "🖧" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /DatWeatherDoe/Config/APIKeyParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIKeyParser.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/11/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class APIKeyParser { 12 | func parse() -> String { 13 | guard let apiKey = Bundle.main.infoDictionary?["WEATHER_API_KEY"] as? String else { 14 | fatalError("Unable to find OPENWEATHERMAP_APP_ID in `Config.xcconfig`") 15 | } 16 | return apiKey 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DatWeatherDoe/Config/ConfigManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigManager.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/29/16. 6 | // Copyright © 2016 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | protocol ConfigManagerType: AnyObject { 13 | var measurementUnit: String { get set } 14 | var weatherSource: String { get set } 15 | var weatherSourceText: String { get set } 16 | var refreshInterval: TimeInterval { get set } 17 | var isShowingWeatherIcon: Bool { get set } 18 | var isShowingHumidity: Bool { get set } 19 | var isShowingUVIndex: Bool { get set } 20 | var isRoundingOffData: Bool { get set } 21 | var isUnitLetterOff: Bool { get set } 22 | var isUnitSymbolOff: Bool { get set } 23 | var valueSeparator: String { get set } 24 | var isWeatherConditionAsTextEnabled: Bool { get set } 25 | var weatherConditionPosition: String { get set } 26 | 27 | func updateWeatherSource(_ source: WeatherSource, sourceText: String) 28 | func setConfigOptions(_ options: ConfigOptions) 29 | 30 | var parsedMeasurementUnit: MeasurementUnit { get } 31 | } 32 | 33 | final class ConfigManager: ConfigManagerType { 34 | @AppStorage("measurementUnit") 35 | public var measurementUnit = MeasurementUnit.imperial.rawValue 36 | 37 | @AppStorage("weatherSource") 38 | public var weatherSource = WeatherSource.location.rawValue 39 | 40 | @AppStorage("weatherSourceText") 41 | public var weatherSourceText = "" 42 | 43 | @AppStorage("refreshInterval") 44 | public var refreshInterval = RefreshInterval.fifteenMinutes.rawValue 45 | 46 | @AppStorage("isShowingWeatherIcon") 47 | public var isShowingWeatherIcon = true 48 | 49 | @AppStorage("isShowingHumidity") 50 | public var isShowingHumidity = false 51 | 52 | @AppStorage("isShowingUVIndex") 53 | public var isShowingUVIndex = false 54 | 55 | @AppStorage("isRoundingOffData") 56 | public var isRoundingOffData = false 57 | 58 | @AppStorage("isUnitLetterOff") 59 | public var isUnitLetterOff = false 60 | 61 | @AppStorage("isUnitSymbolOff") 62 | public var isUnitSymbolOff = false 63 | 64 | @AppStorage("valueSeparator") 65 | public var valueSeparator = "\u{007C}" 66 | 67 | @AppStorage("isWeatherConditionAsTextEnabled") 68 | public var isWeatherConditionAsTextEnabled = false 69 | 70 | @AppStorage("weatherConditionPosition") 71 | public var weatherConditionPosition = WeatherConditionPosition.beforeTemperature.rawValue 72 | 73 | func updateWeatherSource(_ source: WeatherSource, sourceText: String) { 74 | weatherSource = source.rawValue 75 | weatherSourceText = source == .location ? "" : sourceText 76 | } 77 | 78 | func setConfigOptions(_ options: ConfigOptions) { 79 | refreshInterval = options.refreshInterval.rawValue 80 | isShowingHumidity = options.isShowingHumidity 81 | isShowingUVIndex = options.isShowingUVIndex 82 | isRoundingOffData = options.isRoundingOffData 83 | isUnitLetterOff = options.isUnitLetterOff 84 | isUnitSymbolOff = options.isUnitSymbolOff 85 | valueSeparator = options.valueSeparator 86 | isWeatherConditionAsTextEnabled = options.isWeatherConditionAsTextEnabled 87 | } 88 | 89 | var parsedMeasurementUnit: MeasurementUnit { 90 | MeasurementUnit(rawValue: measurementUnit) ?? .imperial 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /DatWeatherDoe/Config/ConfigOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigOptions.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/3/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ConfigOptions { 12 | let refreshInterval: RefreshInterval 13 | let isShowingHumidity: Bool 14 | let isShowingUVIndex: Bool 15 | let isRoundingOffData: Bool 16 | let isUnitLetterOff: Bool 17 | let isUnitSymbolOff: Bool 18 | let valueSeparator: String 19 | let isWeatherConditionAsTextEnabled: Bool 20 | } 21 | -------------------------------------------------------------------------------- /DatWeatherDoe/DatWeatherDoeApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatWeatherDoeApp.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/17/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import MenuBarExtraAccess 10 | import OSLog 11 | import SwiftUI 12 | 13 | @main 14 | struct DatWeatherDoeApp: App { 15 | @State private var configManager: ConfigManager 16 | @ObservedObject private var viewModel: WeatherViewModel 17 | @State private var isMenuPresented: Bool = false 18 | @State private var statusItem: NSStatusItem? 19 | 20 | init() { 21 | configManager = ConfigManager() 22 | 23 | let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "bundleID", category: "main") 24 | viewModel = WeatherViewModel( 25 | locationFetcher: SystemLocationFetcher(logger: logger), 26 | weatherFactory: WeatherRepositoryFactory( 27 | appId: APIKeyParser().parse(), 28 | networkClient: NetworkClient(), 29 | logger: logger 30 | ), 31 | configManager: ConfigManager(), 32 | logger: logger 33 | ) 34 | viewModel.setup(with: WeatherDataFormatter(configManager: configManager)) 35 | } 36 | 37 | var body: some Scene { 38 | MenuBarExtra( 39 | content: { 40 | MenuView( 41 | viewModel: viewModel, 42 | onSeeWeather: { 43 | viewModel.seeForecastForCurrentCity() 44 | closePopover() 45 | }, 46 | onRefresh: { 47 | viewModel.getUpdatedWeatherAfterRefresh() 48 | closePopover() 49 | }, 50 | onSave: { 51 | viewModel.getUpdatedWeatherAfterRefresh() 52 | closePopover() 53 | } 54 | ) 55 | }, 56 | label: { 57 | StatusBarView(weatherResult: viewModel.weatherResult) 58 | .onAppear { 59 | viewModel.getUpdatedWeatherAfterRefresh() 60 | } 61 | } 62 | ) 63 | .menuBarExtraAccess(isPresented: $isMenuPresented) { statusItem in 64 | self.statusItem = statusItem 65 | } 66 | .windowStyle(.hiddenTitleBar) 67 | .menuBarExtraStyle(.window) 68 | } 69 | 70 | private func closePopover() { 71 | statusItem?.togglePresented() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /DatWeatherDoe/Localization/en.xcloc/Localized Contents/en.xliff: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 |
7 | 8 | 9 | DatWeatherDoe 10 | DatWeatherDoe 11 | Bundle name 12 | 13 | 14 | Copyright © 2016 Inder Dhir. All rights reserved. 15 | Copyright © 2016 Inder Dhir. All rights reserved. 16 | Copyright (human-readable) 17 | 18 | 19 | DatWeatherDoe optionally uses your current location to get the weather 20 | DatWeatherDoe optionally uses your current location to get the weather 21 | Privacy - Location When In Use Usage Description 22 | 23 | 24 |
25 | 26 |
27 | 28 |
29 | 30 | 31 | Customize Toolbar… 32 | Customize Toolbar… 33 | Class = "NSMenuItem"; title = "Customize Toolbar…"; ObjectID = "1UK-8n-QPP"; 34 | 35 | 36 | DatWeatherDoe 37 | DatWeatherDoe 38 | Class = "NSMenuItem"; title = "DatWeatherDoe"; ObjectID = "1Xt-HY-uBw"; 39 | 40 | 41 | Find 42 | Find 43 | Class = "NSMenu"; title = "Find"; ObjectID = "1b7-l0-nxx"; 44 | 45 | 46 | Lower 47 | Lower 48 | Class = "NSMenuItem"; title = "Lower"; ObjectID = "1tx-W0-xDw"; 49 | 50 | 51 | Raise 52 | Raise 53 | Class = "NSMenuItem"; title = "Raise"; ObjectID = "2h7-ER-AoG"; 54 | 55 | 56 | Transformations 57 | Transformations 58 | Class = "NSMenuItem"; title = "Transformations"; ObjectID = "2oI-Rn-ZJC"; 59 | 60 | 61 | Spelling 62 | Spelling 63 | Class = "NSMenu"; title = "Spelling"; ObjectID = "3IN-sU-3Bg"; 64 | 65 | 66 | Use Default 67 | Use Default 68 | Class = "NSMenuItem"; title = "Use Default"; ObjectID = "3Om-Ey-2VK"; 69 | 70 | 71 | Speech 72 | Speech 73 | Class = "NSMenu"; title = "Speech"; ObjectID = "3rS-ZA-NoH"; 74 | 75 | 76 | Find 77 | Find 78 | Class = "NSMenuItem"; title = "Find"; ObjectID = "4EN-yA-p0u"; 79 | 80 | 81 | Quit DatWeatherDoe 82 | Quit DatWeatherDoe 83 | Class = "NSMenuItem"; title = "Quit DatWeatherDoe"; ObjectID = "4sb-4s-VLi"; 84 | 85 | 86 | Edit 87 | Edit 88 | Class = "NSMenuItem"; title = "Edit"; ObjectID = "5QF-Oa-p0T"; 89 | 90 | 91 | Copy Style 92 | Copy Style 93 | Class = "NSMenuItem"; title = "Copy Style"; ObjectID = "5Vv-lz-BsD"; 94 | 95 | 96 | About DatWeatherDoe 97 | About DatWeatherDoe 98 | Class = "NSMenuItem"; title = "About DatWeatherDoe"; ObjectID = "5kV-Vb-QxS"; 99 | 100 | 101 | Redo 102 | Redo 103 | Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; 104 | 105 | 106 | Writing Direction 107 | Writing Direction 108 | Class = "NSMenu"; title = "Writing Direction"; ObjectID = "8mr-sm-Yjd"; 109 | 110 | 111 | Substitutions 112 | Substitutions 113 | Class = "NSMenuItem"; title = "Substitutions"; ObjectID = "9ic-FL-obx"; 114 | 115 | 116 | Smart Copy/Paste 117 | Smart Copy/Paste 118 | Class = "NSMenuItem"; title = "Smart Copy/Paste"; ObjectID = "9yt-4B-nSM"; 119 | 120 | 121 | Tighten 122 | Tighten 123 | Class = "NSMenuItem"; title = "Tighten"; ObjectID = "46P-cB-AYj"; 124 | 125 | 126 | Correct Spelling Automatically 127 | Correct Spelling Automatically 128 | Class = "NSMenuItem"; title = "Correct Spelling Automatically"; ObjectID = "78Y-hA-62v"; 129 | 130 | 131 | Main Menu 132 | Main Menu 133 | Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6"; 134 | 135 | 136 | Preferences… 137 | Preferences… 138 | Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; 139 | 140 | 141 | Left to Right 142 | Left to Right 143 | Class = "NSMenuItem"; title = "\tLeft to Right"; ObjectID = "BgM-ve-c93"; 144 | 145 | 146 | Save As… 147 | Save As… 148 | Class = "NSMenuItem"; title = "Save As…"; ObjectID = "Bw7-FT-i3A"; 149 | 150 | 151 | Close 152 | Close 153 | Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; 154 | 155 | 156 | Spelling and Grammar 157 | Spelling and Grammar 158 | Class = "NSMenuItem"; title = "Spelling and Grammar"; ObjectID = "Dv1-io-Yv7"; 159 | 160 | 161 | Help 162 | Help 163 | Class = "NSMenu"; title = "Help"; ObjectID = "F2S-fz-NVQ"; 164 | 165 | 166 | DatWeatherDoe Help 167 | DatWeatherDoe Help 168 | Class = "NSMenuItem"; title = "DatWeatherDoe Help"; ObjectID = "FKE-Sm-Kum"; 169 | 170 | 171 | Text 172 | Text 173 | Class = "NSMenuItem"; title = "Text"; ObjectID = "Fal-I4-PZk"; 174 | 175 | 176 | Substitutions 177 | Substitutions 178 | Class = "NSMenu"; title = "Substitutions"; ObjectID = "FeM-D8-WVr"; 179 | 180 | 181 | Bold 182 | Bold 183 | Class = "NSMenuItem"; title = "Bold"; ObjectID = "GB9-OM-e27"; 184 | 185 | 186 | Format 187 | Format 188 | Class = "NSMenu"; title = "Format"; ObjectID = "GEO-Iw-cKr"; 189 | 190 | 191 | Use Default 192 | Use Default 193 | Class = "NSMenuItem"; title = "Use Default"; ObjectID = "GUa-eO-cwY"; 194 | 195 | 196 | Font 197 | Font 198 | Class = "NSMenuItem"; title = "Font"; ObjectID = "Gi5-1S-RQB"; 199 | 200 | 201 | Writing Direction 202 | Writing Direction 203 | Class = "NSMenuItem"; title = "Writing Direction"; ObjectID = "H1b-Si-o9J"; 204 | 205 | 206 | View 207 | View 208 | Class = "NSMenuItem"; title = "View"; ObjectID = "H8h-7b-M4v"; 209 | 210 | 211 | Text Replacement 212 | Text Replacement 213 | Class = "NSMenuItem"; title = "Text Replacement"; ObjectID = "HFQ-gK-NFA"; 214 | 215 | 216 | Show Spelling and Grammar 217 | Show Spelling and Grammar 218 | Class = "NSMenuItem"; title = "Show Spelling and Grammar"; ObjectID = "HFo-cy-zxI"; 219 | 220 | 221 | View 222 | View 223 | Class = "NSMenu"; title = "View"; ObjectID = "HyV-fh-RgO"; 224 | 225 | 226 | Subscript 227 | Subscript 228 | Class = "NSMenuItem"; title = "Subscript"; ObjectID = "I0S-gh-46l"; 229 | 230 | 231 | Open… 232 | Open… 233 | Class = "NSMenuItem"; title = "Open…"; ObjectID = "IAo-SY-fd9"; 234 | 235 | 236 | Justify 237 | Justify 238 | Class = "NSMenuItem"; title = "Justify"; ObjectID = "J5U-5w-g23"; 239 | 240 | 241 | Use None 242 | Use None 243 | Class = "NSMenuItem"; title = "Use None"; ObjectID = "J7y-lM-qPV"; 244 | 245 | 246 | Revert to Saved 247 | Revert to Saved 248 | Class = "NSMenuItem"; title = "Revert to Saved"; ObjectID = "KaW-ft-85H"; 249 | 250 | 251 | Show All 252 | Show All 253 | Class = "NSMenuItem"; title = "Show All"; ObjectID = "Kd2-mp-pUS"; 254 | 255 | 256 | Bring All to Front 257 | Bring All to Front 258 | Class = "NSMenuItem"; title = "Bring All to Front"; ObjectID = "LE2-aR-0XJ"; 259 | 260 | 261 | Paste Ruler 262 | Paste Ruler 263 | Class = "NSMenuItem"; title = "Paste Ruler"; ObjectID = "LVM-kO-fVI"; 264 | 265 | 266 | Left to Right 267 | Left to Right 268 | Class = "NSMenuItem"; title = "\tLeft to Right"; ObjectID = "Lbh-J2-qVU"; 269 | 270 | 271 | Copy Ruler 272 | Copy Ruler 273 | Class = "NSMenuItem"; title = "Copy Ruler"; ObjectID = "MkV-Pr-PK5"; 274 | 275 | 276 | Services 277 | Services 278 | Class = "NSMenuItem"; title = "Services"; ObjectID = "NMo-om-nkz"; 279 | 280 | 281 | Default 282 | Default 283 | Class = "NSMenuItem"; title = "\tDefault"; ObjectID = "Nop-cj-93Q"; 284 | 285 | 286 | Minimize 287 | Minimize 288 | Class = "NSMenuItem"; title = "Minimize"; ObjectID = "OY7-WF-poV"; 289 | 290 | 291 | Baseline 292 | Baseline 293 | Class = "NSMenuItem"; title = "Baseline"; ObjectID = "OaQ-X3-Vso"; 294 | 295 | 296 | Hide DatWeatherDoe 297 | Hide DatWeatherDoe 298 | Class = "NSMenuItem"; title = "Hide DatWeatherDoe"; ObjectID = "Olw-nP-bQN"; 299 | 300 | 301 | Find Previous 302 | Find Previous 303 | Class = "NSMenuItem"; title = "Find Previous"; ObjectID = "OwM-mh-QMV"; 304 | 305 | 306 | Stop Speaking 307 | Stop Speaking 308 | Class = "NSMenuItem"; title = "Stop Speaking"; ObjectID = "Oyz-dy-DGm"; 309 | 310 | 311 | Bigger 312 | Bigger 313 | Class = "NSMenuItem"; title = "Bigger"; ObjectID = "Ptp-SP-VEL"; 314 | 315 | 316 | Show Fonts 317 | Show Fonts 318 | Class = "NSMenuItem"; title = "Show Fonts"; ObjectID = "Q5e-8K-NDq"; 319 | 320 | 321 | DatWeatherDoe 322 | DatWeatherDoe 323 | Class = "NSWindow"; title = "DatWeatherDoe"; ObjectID = "QvC-M9-y7g"; 324 | 325 | 326 | Zoom 327 | Zoom 328 | Class = "NSMenuItem"; title = "Zoom"; ObjectID = "R4o-n2-Eq4"; 329 | 330 | 331 | Right to Left 332 | Right to Left 333 | Class = "NSMenuItem"; title = "\tRight to Left"; ObjectID = "RB4-Sm-HuC"; 334 | 335 | 336 | Superscript 337 | Superscript 338 | Class = "NSMenuItem"; title = "Superscript"; ObjectID = "Rqc-34-cIF"; 339 | 340 | 341 | Select All 342 | Select All 343 | Class = "NSMenuItem"; title = "Select All"; ObjectID = "Ruw-6m-B2m"; 344 | 345 | 346 | Jump to Selection 347 | Jump to Selection 348 | Class = "NSMenuItem"; title = "Jump to Selection"; ObjectID = "S0p-oC-mLd"; 349 | 350 | 351 | Window 352 | Window 353 | Class = "NSMenu"; title = "Window"; ObjectID = "Td7-aD-5lo"; 354 | 355 | 356 | Capitalize 357 | Capitalize 358 | Class = "NSMenuItem"; title = "Capitalize"; ObjectID = "UEZ-Bs-lqG"; 359 | 360 | 361 | Center 362 | Center 363 | Class = "NSMenuItem"; title = "Center"; ObjectID = "VIY-Ag-zcb"; 364 | 365 | 366 | Hide Others 367 | Hide Others 368 | Class = "NSMenuItem"; title = "Hide Others"; ObjectID = "Vdr-fp-XzO"; 369 | 370 | 371 | Italic 372 | Italic 373 | Class = "NSMenuItem"; title = "Italic"; ObjectID = "Vjx-xi-njq"; 374 | 375 | 376 | Edit 377 | Edit 378 | Class = "NSMenu"; title = "Edit"; ObjectID = "W48-6f-4Dl"; 379 | 380 | 381 | Underline 382 | Underline 383 | Class = "NSMenuItem"; title = "Underline"; ObjectID = "WRG-CD-K1S"; 384 | 385 | 386 | New 387 | New 388 | Class = "NSMenuItem"; title = "New"; ObjectID = "Was-JA-tGl"; 389 | 390 | 391 | Paste and Match Style 392 | Paste and Match Style 393 | Class = "NSMenuItem"; title = "Paste and Match Style"; ObjectID = "WeT-3V-zwk"; 394 | 395 | 396 | Find… 397 | Find… 398 | Class = "NSMenuItem"; title = "Find…"; ObjectID = "Xz5-n4-O0W"; 399 | 400 | 401 | Find and Replace… 402 | Find and Replace… 403 | Class = "NSMenuItem"; title = "Find and Replace…"; ObjectID = "YEy-JH-Tfz"; 404 | 405 | 406 | Default 407 | Default 408 | Class = "NSMenuItem"; title = "\tDefault"; ObjectID = "YGs-j5-SAR"; 409 | 410 | 411 | Start Speaking 412 | Start Speaking 413 | Class = "NSMenuItem"; title = "Start Speaking"; ObjectID = "Ynk-f8-cLZ"; 414 | 415 | 416 | Align Left 417 | Align Left 418 | Class = "NSMenuItem"; title = "Align Left"; ObjectID = "ZM1-6Q-yy1"; 419 | 420 | 421 | Paragraph 422 | Paragraph 423 | Class = "NSMenuItem"; title = "Paragraph"; ObjectID = "ZvO-Gk-QUH"; 424 | 425 | 426 | Print… 427 | Print… 428 | Class = "NSMenuItem"; title = "Print…"; ObjectID = "aTl-1u-JFS"; 429 | 430 | 431 | Window 432 | Window 433 | Class = "NSMenuItem"; title = "Window"; ObjectID = "aUF-d1-5bR"; 434 | 435 | 436 | Font 437 | Font 438 | Class = "NSMenu"; title = "Font"; ObjectID = "aXa-aM-Jaq"; 439 | 440 | 441 | Use Default 442 | Use Default 443 | Class = "NSMenuItem"; title = "Use Default"; ObjectID = "agt-UL-0e3"; 444 | 445 | 446 | Show Colors 447 | Show Colors 448 | Class = "NSMenuItem"; title = "Show Colors"; ObjectID = "bgn-CT-cEk"; 449 | 450 | 451 | File 452 | File 453 | Class = "NSMenu"; title = "File"; ObjectID = "bib-Uj-vzu"; 454 | 455 | 456 | Use Selection for Find 457 | Use Selection for Find 458 | Class = "NSMenuItem"; title = "Use Selection for Find"; ObjectID = "buJ-ug-pKt"; 459 | 460 | 461 | Transformations 462 | Transformations 463 | Class = "NSMenu"; title = "Transformations"; ObjectID = "c8a-y6-VQd"; 464 | 465 | 466 | Use None 467 | Use None 468 | Class = "NSMenuItem"; title = "Use None"; ObjectID = "cDB-IK-hbR"; 469 | 470 | 471 | Selection 472 | Selection 473 | Class = "NSMenuItem"; title = "Selection"; ObjectID = "cqv-fj-IhA"; 474 | 475 | 476 | Smart Links 477 | Smart Links 478 | Class = "NSMenuItem"; title = "Smart Links"; ObjectID = "cwL-P1-jid"; 479 | 480 | 481 | Make Lower Case 482 | Make Lower Case 483 | Class = "NSMenuItem"; title = "Make Lower Case"; ObjectID = "d9M-CD-aMd"; 484 | 485 | 486 | Text 487 | Text 488 | Class = "NSMenu"; title = "Text"; ObjectID = "d9c-me-L2H"; 489 | 490 | 491 | File 492 | File 493 | Class = "NSMenuItem"; title = "File"; ObjectID = "dMs-cI-mzQ"; 494 | 495 | 496 | Undo 497 | Undo 498 | Class = "NSMenuItem"; title = "Undo"; ObjectID = "dRJ-4n-Yzg"; 499 | 500 | 501 | Paste 502 | Paste 503 | Class = "NSMenuItem"; title = "Paste"; ObjectID = "gVA-U4-sdL"; 504 | 505 | 506 | Smart Quotes 507 | Smart Quotes 508 | Class = "NSMenuItem"; title = "Smart Quotes"; ObjectID = "hQb-2v-fYv"; 509 | 510 | 511 | Check Document Now 512 | Check Document Now 513 | Class = "NSMenuItem"; title = "Check Document Now"; ObjectID = "hz2-CU-CR7"; 514 | 515 | 516 | Services 517 | Services 518 | Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; 519 | 520 | 521 | Smaller 522 | Smaller 523 | Class = "NSMenuItem"; title = "Smaller"; ObjectID = "i1d-Er-qST"; 524 | 525 | 526 | Baseline 527 | Baseline 528 | Class = "NSMenu"; title = "Baseline"; ObjectID = "ijk-EB-dga"; 529 | 530 | 531 | Kern 532 | Kern 533 | Class = "NSMenuItem"; title = "Kern"; ObjectID = "jBQ-r6-VK2"; 534 | 535 | 536 | Right to Left 537 | Right to Left 538 | Class = "NSMenuItem"; title = "\tRight to Left"; ObjectID = "jFq-tB-4Kx"; 539 | 540 | 541 | Format 542 | Format 543 | Class = "NSMenuItem"; title = "Format"; ObjectID = "jxT-CU-nIS"; 544 | 545 | 546 | Check Grammar With Spelling 547 | Check Grammar With Spelling 548 | Class = "NSMenuItem"; title = "Check Grammar With Spelling"; ObjectID = "mK6-2p-4JG"; 549 | 550 | 551 | Ligatures 552 | Ligatures 553 | Class = "NSMenuItem"; title = "Ligatures"; ObjectID = "o6e-r0-MWq"; 554 | 555 | 556 | Open Recent 557 | Open Recent 558 | Class = "NSMenu"; title = "Open Recent"; ObjectID = "oas-Oc-fiZ"; 559 | 560 | 561 | Loosen 562 | Loosen 563 | Class = "NSMenuItem"; title = "Loosen"; ObjectID = "ogc-rX-tC1"; 564 | 565 | 566 | Delete 567 | Delete 568 | Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; 569 | 570 | 571 | Save… 572 | Save… 573 | Class = "NSMenuItem"; title = "Save…"; ObjectID = "pxx-59-PXV"; 574 | 575 | 576 | Find Next 577 | Find Next 578 | Class = "NSMenuItem"; title = "Find Next"; ObjectID = "q09-fT-Sye"; 579 | 580 | 581 | Page Setup… 582 | Page Setup… 583 | Class = "NSMenuItem"; title = "Page Setup…"; ObjectID = "qIS-W8-SiK"; 584 | 585 | 586 | Check Spelling While Typing 587 | Check Spelling While Typing 588 | Class = "NSMenuItem"; title = "Check Spelling While Typing"; ObjectID = "rbD-Rh-wIN"; 589 | 590 | 591 | Smart Dashes 592 | Smart Dashes 593 | Class = "NSMenuItem"; title = "Smart Dashes"; ObjectID = "rgM-f4-ycn"; 594 | 595 | 596 | Show Toolbar 597 | Show Toolbar 598 | Class = "NSMenuItem"; title = "Show Toolbar"; ObjectID = "snW-S8-Cw5"; 599 | 600 | 601 | Data Detectors 602 | Data Detectors 603 | Class = "NSMenuItem"; title = "Data Detectors"; ObjectID = "tRr-pd-1PS"; 604 | 605 | 606 | Open Recent 607 | Open Recent 608 | Class = "NSMenuItem"; title = "Open Recent"; ObjectID = "tXI-mr-wws"; 609 | 610 | 611 | Kern 612 | Kern 613 | Class = "NSMenu"; title = "Kern"; ObjectID = "tlD-Oa-oAM"; 614 | 615 | 616 | DatWeatherDoe 617 | DatWeatherDoe 618 | Class = "NSMenu"; title = "DatWeatherDoe"; ObjectID = "uQy-DD-JDr"; 619 | 620 | 621 | Cut 622 | Cut 623 | Class = "NSMenuItem"; title = "Cut"; ObjectID = "uRl-iY-unG"; 624 | 625 | 626 | Paste Style 627 | Paste Style 628 | Class = "NSMenuItem"; title = "Paste Style"; ObjectID = "vKC-jM-MkH"; 629 | 630 | 631 | Show Ruler 632 | Show Ruler 633 | Class = "NSMenuItem"; title = "Show Ruler"; ObjectID = "vLm-3I-IUL"; 634 | 635 | 636 | Clear Menu 637 | Clear Menu 638 | Class = "NSMenuItem"; title = "Clear Menu"; ObjectID = "vNY-rz-j42"; 639 | 640 | 641 | Make Upper Case 642 | Make Upper Case 643 | Class = "NSMenuItem"; title = "Make Upper Case"; ObjectID = "vmV-6d-7jI"; 644 | 645 | 646 | Ligatures 647 | Ligatures 648 | Class = "NSMenu"; title = "Ligatures"; ObjectID = "w0m-vy-SC9"; 649 | 650 | 651 | Align Right 652 | Align Right 653 | Class = "NSMenuItem"; title = "Align Right"; ObjectID = "wb2-vD-lq4"; 654 | 655 | 656 | Help 657 | Help 658 | Class = "NSMenuItem"; title = "Help"; ObjectID = "wpr-3q-Mcd"; 659 | 660 | 661 | Copy 662 | Copy 663 | Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU"; 664 | 665 | 666 | Use All 667 | Use All 668 | Class = "NSMenuItem"; title = "Use All"; ObjectID = "xQD-1f-W4t"; 669 | 670 | 671 | Speech 672 | Speech 673 | Class = "NSMenuItem"; title = "Speech"; ObjectID = "xrE-MZ-jX0"; 674 | 675 | 676 | Show Substitutions 677 | Show Substitutions 678 | Class = "NSMenuItem"; title = "Show Substitutions"; ObjectID = "z6F-FW-3nz"; 679 | 680 | 681 |
682 |
683 | -------------------------------------------------------------------------------- /DatWeatherDoe/Localization/en.xcloc/Source Contents/DatWeatherDoe/Resources/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Bundle name */ 2 | "CFBundleName" = "DatWeatherDoe"; 3 | /* Copyright (human-readable) */ 4 | "NSHumanReadableCopyright" = "Copyright © 2016 Inder Dhir. All rights reserved."; 5 | /* Privacy - Location When In Use Usage Description */ 6 | "NSLocationWhenInUseUsageDescription" = "DatWeatherDoe optionally uses your current location to get the weather"; 7 | -------------------------------------------------------------------------------- /DatWeatherDoe/Localization/en.xcloc/contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "developmentRegion" : "en", 3 | "project" : "DatWeatherDoe.xcodeproj", 4 | "targetLocale" : "en", 5 | "toolInfo" : { 6 | "toolBuildNumber" : "12E262", 7 | "toolID" : "com.apple.dt.xcode", 8 | "toolName" : "Xcode", 9 | "toolVersion" : "12.5" 10 | }, 11 | "version" : "1.0" 12 | } -------------------------------------------------------------------------------- /DatWeatherDoe/Reachability/WeatherReachability.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherReachability.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/11/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import OSLog 10 | import Reachability 11 | 12 | final class NetworkReachability { 13 | private let logger: Logger 14 | private var reachability: Reachability? 15 | private var retryWhenReachable = false 16 | 17 | init( 18 | logger: Logger, 19 | onBecomingReachable: @escaping () -> Void 20 | ) { 21 | self.logger = logger 22 | 23 | setupWith(callback: onBecomingReachable) 24 | } 25 | 26 | private func setupWith(callback: @escaping () -> Void) { 27 | do { 28 | reachability = try Reachability() 29 | try reachability?.startNotifier() 30 | updateReachabilityWhenReachable(callback: callback) 31 | updateReachabilityWhenUnreachable() 32 | } catch { 33 | logger.error("Reachability error!") 34 | } 35 | } 36 | 37 | private func updateReachabilityWhenReachable(callback: @escaping () -> Void) { 38 | reachability?.whenReachable = { [weak self] _ in 39 | self?.logger.debug("Reachability status: Reachable") 40 | 41 | if self?.retryWhenReachable == true { 42 | self?.retryWhenReachable = false 43 | callback() 44 | } 45 | } 46 | } 47 | 48 | private func updateReachabilityWhenUnreachable() { 49 | reachability?.whenUnreachable = { [weak self] _ in 50 | self?.logger.debug("Reachability status: Unreachable") 51 | 52 | self?.retryWhenReachable = true 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256-1.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32-1.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512-1.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32-1.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256-1.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512-1.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/DatWeatherDoe.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | com.apple.security.personal-information.location 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/DevelopmentAssets/TestData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestData.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/25/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | let uvIndicesFor24Hours: [HourlyUVIndex] = { 12 | var arr: [HourlyUVIndex] = [] 13 | for i in 1 ... 24 { 14 | arr.append(HourlyUVIndex(uv: 0)) 15 | } 16 | return arr 17 | }() 18 | 19 | let response = WeatherAPIResponse( 20 | locationName: "New York City", 21 | temperatureData: .init( 22 | tempCelsius: 31.1, 23 | feelsLikeTempCelsius: 29.1, 24 | tempFahrenheit: 88.0, 25 | feelsLikeTempFahrenheit: 84.4 26 | ), 27 | isDay: true, 28 | weatherConditionCode: 1000, 29 | humidity: 45, 30 | windData: .init(speedMph: 12.3, degrees: 305, direction: "NW"), 31 | uvIndex: 7.0, 32 | forecastDayData: .init( 33 | temp: .init( 34 | maxTempC: 32.8, maxTempF: 91.0, minTempC: 20.6, minTempF: 69.2 35 | ), 36 | astro: .init(sunrise: "05:26 AM", sunset: "08:31 PM"), 37 | hour: uvIndicesFor24Hours 38 | ), 39 | airQualityIndex: .good 40 | ) 41 | -------------------------------------------------------------------------------- /DatWeatherDoe/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WEATHER_API_KEY 6 | ${WEATHER_API_KEY} 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleSignature 24 | ???? 25 | CFBundleVersion 26 | $(CURRENT_PROJECT_VERSION) 27 | LSApplicationCategoryType 28 | public.app-category.weather 29 | LSMinimumSystemVersion 30 | $(MACOSX_DEPLOYMENT_TARGET) 31 | LSUIElement 32 | 33 | NSHumanReadableCopyright 34 | Copyright © 2016 Inder Dhir. All rights reserved. 35 | NSLocationWhenInUseUsageDescription 36 | DatWeatherDoe optionally uses your current location to get the weather 37 | NSMainNibFile 38 | MainMenu 39 | NSPrincipalClass 40 | NSApplication 41 | 42 | 43 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/ConfigureOptionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigureOptionsView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/3/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ConfigureOptionsView: View { 12 | @ObservedObject var viewModel: ConfigureViewModel 13 | 14 | var body: some View { 15 | Grid(verticalSpacing: 16) { 16 | HStack { 17 | Text(LocalizedStringKey("Unit")) 18 | Spacer() 19 | Picker("", selection: $viewModel.measurementUnit) { 20 | Text(LocalizedStringKey("Metric")).tag(MeasurementUnit.metric) 21 | Text(LocalizedStringKey("Imperial")).tag(MeasurementUnit.imperial) 22 | Text(LocalizedStringKey("All")).tag(MeasurementUnit.all) 23 | } 24 | .frame(width: 120) 25 | } 26 | 27 | ConfigureWeatherOptionsView(viewModel: viewModel) 28 | 29 | HStack { 30 | Text(LocalizedStringKey("Refresh Interval")) 31 | Spacer() 32 | Picker("", selection: $viewModel.refreshInterval) { 33 | Text(LocalizedStringKey("5 min")).tag(RefreshInterval.fiveMinutes) 34 | Text(LocalizedStringKey("15 min")).tag(RefreshInterval.fifteenMinutes) 35 | Text(LocalizedStringKey("30 min")).tag(RefreshInterval.thirtyMinutes) 36 | Text(LocalizedStringKey("60 min")).tag(RefreshInterval.sixtyMinutes) 37 | } 38 | .frame(width: 120) 39 | } 40 | 41 | HStack { 42 | Text(LocalizedStringKey("Show Weather Icon")) 43 | Spacer() 44 | Toggle(isOn: $viewModel.isShowingWeatherIcon) {} 45 | } 46 | 47 | HStack { 48 | Text(LocalizedStringKey("Show Humidity")) 49 | Spacer() 50 | Toggle(isOn: $viewModel.isShowingHumidity) {} 51 | } 52 | 53 | HStack { 54 | Text(LocalizedStringKey("Show UV Index")) 55 | Spacer() 56 | Toggle(isOn: $viewModel.isShowingUVIndex) {} 57 | } 58 | 59 | HStack { 60 | Text(LocalizedStringKey("Round-off Data")) 61 | Spacer() 62 | Toggle(isOn: $viewModel.isRoundingOffData) {} 63 | } 64 | 65 | ConfigureUnitOptionsView(viewModel: viewModel) 66 | 67 | ConfigureValueSeparatorOptionsView(viewModel: viewModel) 68 | 69 | HStack { 70 | Text(LocalizedStringKey("Weather Condition Text")) 71 | Spacer() 72 | Toggle(isOn: $viewModel.isWeatherConditionAsTextEnabled) {} 73 | } 74 | 75 | HStack { 76 | Text(LocalizedStringKey("Weather Condition Position")) 77 | Spacer() 78 | Picker("", selection: $viewModel.weatherConditionPosition) { 79 | Text(LocalizedStringKey("Before Temperature")) 80 | .tag(WeatherConditionPosition.beforeTemperature) 81 | Text(LocalizedStringKey("After Temperature")) 82 | .tag(WeatherConditionPosition.afterTemperature) 83 | } 84 | .frame(maxWidth: 120) 85 | } 86 | .disabled(!viewModel.isWeatherConditionAsTextEnabled) 87 | } 88 | .padding() 89 | } 90 | } 91 | 92 | struct ConfigureOptionsView_Previews: PreviewProvider { 93 | static var previews: some View { 94 | ConfigureOptionsView( 95 | viewModel: .init(configManager: ConfigManager()) 96 | ) 97 | .frame(width: 380) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/ConfigureUnitOptionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigureUnitOptionsView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/8/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ConfigureUnitOptionsView: View { 12 | @ObservedObject var viewModel: ConfigureViewModel 13 | 14 | var body: some View { 15 | Group { 16 | HStack { 17 | Text(LocalizedStringKey("Hide unit letter")) 18 | Spacer() 19 | Toggle(isOn: $viewModel.isUnitLetterOff) {} 20 | } 21 | 22 | HStack { 23 | Text(LocalizedStringKey("Hide unit ° symbol")) 24 | Spacer() 25 | Toggle(isOn: $viewModel.isUnitSymbolOff) {} 26 | } 27 | } 28 | } 29 | } 30 | 31 | struct ConfigureUnitOptionsView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | Grid { 34 | ConfigureUnitOptionsView( 35 | viewModel: .init(configManager: ConfigManager()) 36 | ) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/ConfigureValueSeparatorOptionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigureValueSeparatorOptionsView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/8/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ConfigureValueSeparatorOptionsView: View { 12 | @ObservedObject var viewModel: ConfigureViewModel 13 | let valueSeparatorPlaceholder = "\u{007C}" 14 | 15 | var body: some View { 16 | HStack { 17 | Text(LocalizedStringKey("Separate values with")) 18 | Spacer() 19 | TextField(valueSeparatorPlaceholder, text: $viewModel.valueSeparator) 20 | .font(.body) 21 | .foregroundColor(.primary) 22 | .frame(width: 114) 23 | } 24 | } 25 | } 26 | 27 | struct ConfigureValueSeparatorOptionsView_Previews: PreviewProvider { 28 | static var previews: some View { 29 | ConfigureValueSeparatorOptionsView( 30 | viewModel: .init(configManager: ConfigManager()) 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/ConfigureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigureView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 2/18/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ConfigureView: View { 12 | @ObservedObject var viewModel: ConfigureViewModel 13 | let version: String 14 | let onSave: () -> Void 15 | let onQuit: () -> Void 16 | 17 | var body: some View { 18 | VStack { 19 | ConfigureOptionsView(viewModel: viewModel) 20 | 21 | HStack { 22 | Text(version) 23 | .font(.footnote) 24 | .fontWeight(.thin) 25 | .frame(maxWidth: .infinity, alignment: .leading) 26 | 27 | CustomButton( 28 | text: LocalizedStringKey("Done"), 29 | shortcutKey: "d", 30 | onClick: onSave 31 | ) 32 | .frame(maxWidth: .infinity, alignment: .center) 33 | 34 | Text(LocalizedStringKey("Quit")) 35 | .foregroundStyle(Color.red) 36 | .onTapGesture(perform: onQuit) 37 | .frame(maxWidth: .infinity, alignment: .trailing) 38 | } 39 | .frame(maxWidth: .infinity) 40 | .padding([.leading, .trailing]) 41 | } 42 | .padding(.bottom) 43 | .frame(width: 380) 44 | } 45 | } 46 | 47 | struct ConfigureView_Previews: PreviewProvider { 48 | static var previews: some View { 49 | ConfigureView( 50 | viewModel: .init(configManager: ConfigManager()), 51 | version: "5.0.0", 52 | onSave: {}, 53 | onQuit: {} 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/ConfigureViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigureViewModel.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 3/20/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | final class ConfigureViewModel: ObservableObject { 13 | @Published var measurementUnit: MeasurementUnit { 14 | didSet { configManager.measurementUnit = measurementUnit.rawValue } 15 | } 16 | 17 | @Published var weatherSource: WeatherSource { 18 | didSet { configManager.weatherSource = weatherSource.rawValue } 19 | } 20 | 21 | @Published var weatherSourceText = "" { 22 | didSet { configManager.weatherSourceText = weatherSourceText } 23 | } 24 | 25 | @Published var refreshInterval: RefreshInterval { 26 | didSet { configManager.refreshInterval = refreshInterval.rawValue } 27 | } 28 | 29 | @Published var isShowingWeatherIcon: Bool { 30 | didSet { configManager.isShowingWeatherIcon = isShowingWeatherIcon } 31 | } 32 | 33 | @Published var isShowingHumidity: Bool { 34 | didSet { configManager.isShowingHumidity = isShowingHumidity } 35 | } 36 | 37 | @Published var isShowingUVIndex: Bool { 38 | didSet { configManager.isShowingUVIndex = isShowingUVIndex } 39 | } 40 | 41 | @Published var isRoundingOffData: Bool { 42 | didSet { configManager.isRoundingOffData = isRoundingOffData } 43 | } 44 | 45 | @Published var isUnitLetterOff: Bool { 46 | didSet { configManager.isUnitLetterOff = isUnitLetterOff } 47 | } 48 | 49 | @Published var isUnitSymbolOff: Bool { 50 | didSet { configManager.isUnitSymbolOff = isUnitSymbolOff } 51 | } 52 | 53 | @Published var valueSeparator = "|" { 54 | didSet { configManager.valueSeparator = valueSeparator } 55 | } 56 | 57 | @Published var isWeatherConditionAsTextEnabled: Bool { 58 | didSet { configManager.isWeatherConditionAsTextEnabled = isWeatherConditionAsTextEnabled } 59 | } 60 | 61 | @Published var weatherConditionPosition: WeatherConditionPosition { 62 | didSet { configManager.weatherConditionPosition = weatherConditionPosition.rawValue } 63 | } 64 | 65 | private let configManager: ConfigManagerType 66 | 67 | init(configManager: ConfigManagerType) { 68 | self.configManager = configManager 69 | 70 | measurementUnit = configManager.parsedMeasurementUnit 71 | weatherSource = WeatherSource(rawValue: configManager.weatherSource) ?? .location 72 | 73 | switch configManager.refreshInterval { 74 | case 300: refreshInterval = .fiveMinutes 75 | case 900: refreshInterval = .fifteenMinutes 76 | case 1800: refreshInterval = .thirtyMinutes 77 | case 3600: refreshInterval = .sixtyMinutes 78 | default: refreshInterval = .fifteenMinutes 79 | } 80 | 81 | isShowingWeatherIcon = configManager.isShowingWeatherIcon 82 | isShowingHumidity = configManager.isShowingHumidity 83 | isShowingUVIndex = configManager.isShowingUVIndex 84 | isRoundingOffData = configManager.isRoundingOffData 85 | isUnitLetterOff = configManager.isUnitLetterOff 86 | isUnitSymbolOff = configManager.isUnitSymbolOff 87 | isWeatherConditionAsTextEnabled = configManager.isWeatherConditionAsTextEnabled 88 | weatherConditionPosition = WeatherConditionPosition(rawValue: configManager.weatherConditionPosition) 89 | ?? .beforeTemperature 90 | } 91 | 92 | func saveConfig() { 93 | configManager.updateWeatherSource(weatherSource, sourceText: weatherSourceText) 94 | configManager.setConfigOptions( 95 | .init( 96 | refreshInterval: refreshInterval, 97 | isShowingHumidity: isShowingHumidity, 98 | isShowingUVIndex: isShowingUVIndex, 99 | isRoundingOffData: isRoundingOffData, 100 | isUnitLetterOff: isUnitLetterOff, 101 | isUnitSymbolOff: isUnitSymbolOff, 102 | valueSeparator: valueSeparator, 103 | isWeatherConditionAsTextEnabled: isWeatherConditionAsTextEnabled 104 | ) 105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/ConfigureWeatherOptionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigureWeatherOptionsView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/8/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ConfigureWeatherOptionsView: View { 12 | @ObservedObject var viewModel: ConfigureViewModel 13 | 14 | var body: some View { 15 | Group { 16 | HStack { 17 | Text(LocalizedStringKey("Weather Source")) 18 | Spacer() 19 | Picker("", selection: $viewModel.weatherSource) { 20 | Text(LocalizedStringKey("Location")).tag(WeatherSource.location) 21 | Text(LocalizedStringKey("Lat/Long")).tag(WeatherSource.latLong) 22 | } 23 | .frame(width: 120) 24 | } 25 | 26 | HStack { 27 | Text(viewModel.weatherSource.textHint) 28 | .font(.caption) 29 | .foregroundColor(.secondary) 30 | Spacer() 31 | TextField(viewModel.weatherSource.placeholder, text: $viewModel.weatherSourceText) 32 | .font(.caption) 33 | .foregroundColor(.secondary) 34 | .disabled(viewModel.weatherSource == .location) 35 | .frame(width: 114) 36 | } 37 | } 38 | } 39 | } 40 | 41 | struct ConfigureWeatherOptionsView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | Grid { 44 | ConfigureWeatherOptionsView( 45 | viewModel: .init(configManager: ConfigManager()) 46 | ) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/Options/MeasurementUnit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeasurementUnit.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 2/7/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum MeasurementUnit: String, CaseIterable, Identifiable { 12 | case metric, imperial, all 13 | 14 | var id: Self { self } 15 | 16 | var temperatureUnit: TemperatureUnit { 17 | switch self { 18 | case .metric: 19 | return .celsius 20 | case .imperial: 21 | return .fahrenheit 22 | case .all: 23 | return .all 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/Options/RefreshInterval.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshInterval.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 5/23/21. 6 | // Copyright © 2021 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum RefreshInterval: TimeInterval, CaseIterable, Identifiable { 12 | case fiveMinutes = 300 13 | case fifteenMinutes = 900 14 | case thirtyMinutes = 1800 15 | case sixtyMinutes = 3600 16 | 17 | var id: Self { self } 18 | 19 | var title: String { 20 | switch self { 21 | case .fiveMinutes: 22 | return NSLocalizedString("5 min", comment: "5 min refresh interval") 23 | case .fifteenMinutes: 24 | return NSLocalizedString("15 min", comment: "15 min refresh interval") 25 | case .thirtyMinutes: 26 | return NSLocalizedString("30 min", comment: "30 min refresh interval") 27 | case .sixtyMinutes: 28 | return NSLocalizedString("60 min", comment: "60 min refresh interval") 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/Options/TemperatureUnit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureUnit.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/8/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum TemperatureUnit: String, CaseIterable, Identifiable { 12 | case fahrenheit, celsius, all 13 | 14 | var id: Self { self } 15 | 16 | var unitString: String { 17 | switch self { 18 | case .fahrenheit: 19 | return "F" 20 | case .celsius: 21 | return "C" 22 | case .all: 23 | return "All" 24 | } 25 | } 26 | 27 | var degreesString: String { 28 | "\u{00B0}\(unitString)" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/Options/WeatherConditionPosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherConditionPosition.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 3/27/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum WeatherConditionPosition: String, Identifiable { 12 | case beforeTemperature, afterTemperature 13 | 14 | var id: Self { self } 15 | 16 | var title: String { 17 | switch self { 18 | case .beforeTemperature: 19 | return NSLocalizedString("Before Temperature", comment: "Weather condition before temperature") 20 | case .afterTemperature: 21 | return NSLocalizedString("After Temperature", comment: "Weather condition after temperature") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Configure/Options/WeatherSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherSource.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 5/23/21. 6 | // Copyright © 2021 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum WeatherSource: String, CaseIterable { 12 | case location, latLong 13 | 14 | var title: String { 15 | switch self { 16 | case .location: 17 | return NSLocalizedString("Location", comment: "Weather based on location") 18 | case .latLong: 19 | return NSLocalizedString("Lat/Long", comment: "Weather based on Lat/Long") 20 | } 21 | } 22 | 23 | var placeholder: String { 24 | switch self { 25 | case .location: 26 | return "" 27 | case .latLong: 28 | return "42,42" 29 | } 30 | } 31 | 32 | var textHint: String { 33 | switch self { 34 | case .location: 35 | return "" 36 | case .latLong: 37 | return NSLocalizedString( 38 | "[latitude],[longitude]", 39 | comment: "Placeholder hint for entering Lat/Long" 40 | ) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Condition/WeatherCondition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherCondition.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/29/16. 6 | // Copyright © 2016 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Foundation 11 | 12 | enum WeatherCondition { 13 | case cloudy, partlyCloudy, partlyCloudyNight 14 | case sunny, clearNight 15 | case snow 16 | case heavyRain, freezingRain, lightRain, partlyCloudyRain 17 | case thunderstorm 18 | case mist, fog 19 | 20 | static func getFallback(isDay: Bool) -> WeatherCondition { 21 | isDay ? .sunny : .clearNight 22 | } 23 | 24 | var symbolName: String { 25 | switch self { 26 | case .cloudy: 27 | "cloud" 28 | case .partlyCloudy: 29 | "cloud.sun" 30 | case .partlyCloudyNight: 31 | "cloud.moon" 32 | case .sunny: 33 | "sun.max" 34 | case .clearNight: 35 | "moon" 36 | case .snow: 37 | "cloud.snow" 38 | case .lightRain, .heavyRain, .freezingRain: 39 | "cloud.rain" 40 | case .partlyCloudyRain: 41 | "cloud.sun.rain" 42 | case .thunderstorm: 43 | "cloud.bolt.rain" 44 | case .mist, .fog: 45 | "cloud.fog" 46 | } 47 | } 48 | 49 | var accessibilityLabel: String { 50 | switch self { 51 | case .cloudy: 52 | "Cloudy" 53 | case .partlyCloudy: 54 | "Partly Cloudy" 55 | case .partlyCloudyNight: 56 | "Partly Cloudy" 57 | case .sunny: 58 | "Sunny" 59 | case .clearNight: 60 | "Clear" 61 | case .snow: 62 | "Snow" 63 | case .lightRain, .heavyRain, .freezingRain: 64 | "Rainy" 65 | case .partlyCloudyRain: 66 | "Partly cloudy with rain" 67 | case .thunderstorm: 68 | "Thunderstorm" 69 | case .mist, .fog: 70 | "Cloudy with Fog" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Condition/WeatherConditionBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherConditionBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/11/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol WeatherConditionBuilderType { 12 | func build() -> WeatherCondition 13 | } 14 | 15 | final class WeatherConditionBuilder: WeatherConditionBuilderType { 16 | private let response: WeatherAPIResponse 17 | 18 | init(response: WeatherAPIResponse) { 19 | self.response = response 20 | } 21 | 22 | func build() -> WeatherCondition { 23 | switch response.weatherConditionCode { 24 | case 1006, 1009: 25 | .cloudy 26 | case 1003: 27 | response.isDay ? .partlyCloudy : .partlyCloudyNight 28 | case 1000: 29 | response.isDay ? .sunny : .clearNight 30 | case 1030: 31 | .mist 32 | case 1135, 1147: 33 | .fog 34 | case 1066, 35 | 1114, 1117, 36 | 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1249, 1252, 1255, 1258, 1261, 1264, 1279, 1282: 37 | .snow 38 | case 1192, 1195, 1243, 1246, 1276: 39 | .heavyRain 40 | case 1069, 1072, 1168, 1171, 1198, 1201, 1204, 1207: 41 | .freezingRain 42 | case 1063, 1150, 1153, 1180, 1183, 1186, 1189, 1240: 43 | response.isDay ? .partlyCloudyRain : .lightRain 44 | case 1087, 1273: 45 | .thunderstorm 46 | default: 47 | WeatherCondition.getFallback(isDay: response.isDay) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Condition/WeatherConditionTextMapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherConditionTextMapper.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/11/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol WeatherConditionTextMapperType { 12 | func map(_ condition: WeatherCondition) -> String 13 | } 14 | 15 | final class WeatherConditionTextMapper: WeatherConditionTextMapperType { 16 | func map(_ condition: WeatherCondition) -> String { 17 | switch condition { 18 | case .cloudy: 19 | NSLocalizedString("Cloudy", comment: "Cloudy weather condition") 20 | 21 | case .partlyCloudy, .partlyCloudyNight: 22 | NSLocalizedString("Partly cloudy", comment: "Partly cloudy weather condition") 23 | 24 | case .sunny: 25 | NSLocalizedString("Sunny", comment: "Sunny weather condition") 26 | 27 | case .clearNight: 28 | NSLocalizedString("Clear", comment: "Clear at night weather condition") 29 | 30 | case .snow: 31 | NSLocalizedString("Snow", comment: "Snow weather condition") 32 | 33 | case .heavyRain: 34 | NSLocalizedString("Heavy rain", comment: "Heavy rain weather condition") 35 | 36 | case .freezingRain: 37 | NSLocalizedString("Freezing rain", comment: "Freezing rain weather condition") 38 | 39 | case .lightRain: 40 | NSLocalizedString("Light rain", comment: "Light rain weather condition") 41 | 42 | case .partlyCloudyRain: 43 | NSLocalizedString( 44 | "Partly cloudy with rain", 45 | comment: "Partly cloudy with rain weather condition" 46 | ) 47 | 48 | case .thunderstorm: 49 | NSLocalizedString("Thunderstorm", comment: "Thunderstorm weather condition") 50 | 51 | case .mist: 52 | NSLocalizedString("Mist", comment: "Mist weather condition") 53 | 54 | case .fog: 55 | NSLocalizedString("Fog", comment: "Fog weather condition") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/HumidityTextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HumidityTextBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/15/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | protocol HumidityTextBuilderType { 13 | func build() -> String 14 | } 15 | 16 | final class HumidityTextBuilder: HumidityTextBuilderType { 17 | private let initial: String 18 | private let valueSeparator: String 19 | private let humidity: Int 20 | private let logger: Logger 21 | private let percentString = "\u{0025}" 22 | 23 | private let humidityFormatter: NumberFormatter = { 24 | let formatter = NumberFormatter() 25 | formatter.numberStyle = .none 26 | formatter.maximumFractionDigits = 0 27 | return formatter 28 | }() 29 | 30 | init( 31 | initial: String, 32 | valueSeparator: String, 33 | humidity: Int, 34 | logger: Logger 35 | ) { 36 | self.initial = initial 37 | self.valueSeparator = valueSeparator 38 | self.humidity = humidity 39 | self.logger = logger 40 | } 41 | 42 | func build() -> String { 43 | guard let humidityString = buildHumidity() else { 44 | logger.error("Unable to construct humidity string") 45 | 46 | return initial 47 | } 48 | 49 | return "\(initial) \(valueSeparator) \(humidityString)" 50 | } 51 | 52 | private func buildHumidity() -> String? { 53 | guard let formattedString = buildFormattedString() else { return nil } 54 | 55 | return "\(formattedString)\(percentString)" 56 | } 57 | 58 | private func buildFormattedString() -> String? { 59 | humidityFormatter.string(from: NSNumber(value: humidity)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/SunriseAndSunsetTextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SunriseAndSunsetTextBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Markus Markus on 2022-07-26. 6 | // Copyright © 2022 Markus Mayer. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol SunriseAndSunsetTextBuilderType { 12 | func build() -> String 13 | } 14 | 15 | final class SunriseAndSunsetTextBuilder: SunriseAndSunsetTextBuilderType { 16 | private let sunset: String 17 | private let sunrise: String 18 | 19 | private let upArrowStr = "⬆" 20 | private let downArrowStr = "⬇" 21 | 22 | init(sunset: String, sunrise: String) { 23 | self.sunset = sunset 24 | self.sunrise = sunrise 25 | } 26 | 27 | func build() -> String { 28 | "\(upArrowStr)\(sunrise) \(downArrowStr)\(sunset)" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureForecastTextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureForecastTextBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/25/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol TemperatureForecastTextBuilderType { 12 | func build() -> String 13 | } 14 | 15 | final class TemperatureForecastTextBuilder: TemperatureForecastTextBuilderType { 16 | private let temperatureData: TemperatureData 17 | private let forecastTemperatureData: ForecastTemperatureData 18 | private let options: TemperatureTextBuilder.Options 19 | private let upArrowStr = "⬆" 20 | private let downArrowStr = "⬇" 21 | private let degreeString = "\u{00B0}" 22 | 23 | init( 24 | temperatureData: TemperatureData, 25 | forecastTemperatureData: ForecastTemperatureData, 26 | options: TemperatureTextBuilder.Options 27 | ) { 28 | self.temperatureData = temperatureData 29 | self.forecastTemperatureData = forecastTemperatureData 30 | self.options = options 31 | } 32 | 33 | func build() -> String { 34 | if options.unit == .all { 35 | buildTemperatureForAllUnits() 36 | } else { 37 | buildTemperatureForUnit(options.unit) 38 | } 39 | } 40 | 41 | private func buildTemperatureForAllUnits() -> String { 42 | let feelsLikeTempFahrenheit = buildFormattedTemperature( 43 | temperatureData.feelsLikeTempFahrenheit, unit: .fahrenheit 44 | ) 45 | let feelsLikeTempCelsius = buildFormattedTemperature( 46 | temperatureData.feelsLikeTempCelsius, unit: .celsius 47 | ) 48 | let feelsLikeTemperatureCombined = [feelsLikeTempFahrenheit, feelsLikeTempCelsius] 49 | .compactMap { $0 } 50 | .joined(separator: " / ") 51 | 52 | let maxTempFahrenheit = buildFormattedTemperature( 53 | forecastTemperatureData.maxTempF, unit: .fahrenheit 54 | ) 55 | let maxTempCelsius = buildFormattedTemperature( 56 | forecastTemperatureData.maxTempC, unit: .celsius 57 | ) 58 | let maxTempCombined = [maxTempFahrenheit, maxTempCelsius] 59 | .compactMap { $0 } 60 | .joined(separator: " / ") 61 | let maxTempStr = [upArrowStr, maxTempCombined] 62 | .compactMap { $0 } 63 | .joined() 64 | 65 | let minTempFahrenheit = buildFormattedTemperature( 66 | forecastTemperatureData.minTempF, unit: .fahrenheit 67 | ) 68 | let minTempCelsius = buildFormattedTemperature( 69 | forecastTemperatureData.minTempC, unit: .celsius 70 | ) 71 | let minTempCombined = [minTempFahrenheit, minTempCelsius] 72 | .compactMap { $0 } 73 | .joined(separator: " / ") 74 | let minTempStr = [downArrowStr, minTempCombined] 75 | .compactMap { $0 } 76 | .joined() 77 | 78 | let maxAndMinTempStr = [maxTempStr, minTempStr] 79 | .compactMap { $0 } 80 | .joined(separator: " ") 81 | return [feelsLikeTemperatureCombined, maxAndMinTempStr] 82 | .compactMap { $0 } 83 | .joined(separator: " - ") 84 | } 85 | 86 | private func buildTemperatureForUnit(_ unit: TemperatureUnit) -> String { 87 | let maxTemp = unit == .fahrenheit ? forecastTemperatureData.maxTempF : forecastTemperatureData.maxTempC 88 | let formattedMaxTemp = buildFormattedTemperature(maxTemp, unit: unit) 89 | let maxTempStr = [upArrowStr, formattedMaxTemp] 90 | .compactMap { $0 } 91 | .joined() 92 | 93 | let minTemp = unit == .fahrenheit ? forecastTemperatureData.minTempF : forecastTemperatureData.minTempC 94 | let formatedMinTemp = buildFormattedTemperature(minTemp, unit: unit) 95 | let minTempStr = [downArrowStr, formatedMinTemp] 96 | .compactMap { $0 } 97 | .joined() 98 | 99 | let maxAndMinTempStr = [maxTempStr, minTempStr] 100 | .compactMap { $0 } 101 | .joined(separator: " ") 102 | 103 | let feelsLikeTemp = unit == .fahrenheit ? 104 | temperatureData.feelsLikeTempFahrenheit : 105 | temperatureData.feelsLikeTempCelsius 106 | let formattedFeelsLikeTemp = buildFormattedTemperature(feelsLikeTemp, unit: unit) 107 | return [formattedFeelsLikeTemp, maxAndMinTempStr] 108 | .compactMap { $0 } 109 | .joined(separator: " - ") 110 | } 111 | 112 | private func buildFormattedTemperature( 113 | _ temperatureForUnit: Double, 114 | unit: TemperatureUnit 115 | ) -> String? { 116 | guard let temperatureString = TemperatureFormatter() 117 | .getFormattedTemperatureString(temperatureForUnit, isRoundingOff: options.isRoundingOff) 118 | else { 119 | return nil 120 | } 121 | 122 | return combineTemperatureWithUnitDegrees( 123 | temperature: temperatureString, 124 | unit: unit.unitString, 125 | isUnitLetterOff: options.isUnitLetterOff, 126 | isUnitSymbolOff: options.isUnitSymbolOff 127 | ) 128 | } 129 | 130 | private func combineTemperatureWithUnitDegrees( 131 | temperature: String, 132 | unit: String, 133 | isUnitLetterOff: Bool, 134 | isUnitSymbolOff: Bool 135 | ) -> String { 136 | let unitLetter = isUnitLetterOff ? "" : unit 137 | let unitSymbol = isUnitSymbolOff ? "" : degreeString 138 | return [temperature, unitLetter].joined(separator: unitSymbol) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureFormatter.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/12/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol TemperatureFormatterType { 12 | func getFormattedTemperatureString( 13 | _ temperature: Double, 14 | isRoundingOff: Bool 15 | ) -> String? 16 | } 17 | 18 | final class TemperatureFormatter: TemperatureFormatterType { 19 | private let formatter: NumberFormatter = { 20 | let formatter = NumberFormatter() 21 | formatter.numberStyle = .decimal 22 | formatter.maximumFractionDigits = 1 23 | formatter.roundingMode = .halfUp 24 | return formatter 25 | }() 26 | 27 | func getFormattedTemperatureString( 28 | _ temperature: Double, 29 | isRoundingOff: Bool 30 | ) -> String? { 31 | setupTemperatureRounding(isRoundingOff: isRoundingOff) 32 | return formatTemperatureString(temperature) 33 | } 34 | 35 | private func setupTemperatureRounding(isRoundingOff: Bool) { 36 | formatter.maximumFractionDigits = isRoundingOff ? 0 : 1 37 | } 38 | 39 | private func formatTemperatureString(_ temperature: Double) -> String? { 40 | let formattedTemperature = formatter.string(from: NSNumber(value: temperature)) 41 | return fixRoundingIssues(formattedTemperature) 42 | } 43 | 44 | private func fixRoundingIssues(_ temperature: String?) -> String? { 45 | if temperature == "-0" { 46 | return "0" 47 | } 48 | return temperature 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureTextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureTextBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/15/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | protocol TemperatureTextBuilderType { 10 | func build() -> String? 11 | } 12 | 13 | final class TemperatureTextBuilder: TemperatureTextBuilderType { 14 | struct Options { 15 | let unit: TemperatureUnit 16 | let isRoundingOff: Bool 17 | let isUnitLetterOff: Bool 18 | let isUnitSymbolOff: Bool 19 | } 20 | 21 | private let response: WeatherAPIResponse 22 | private let options: Options 23 | private let temperatureCreator: TemperatureWithDegreesCreatorType 24 | private let degreeString = "\u{00B0}" 25 | 26 | init( 27 | response: WeatherAPIResponse, 28 | options: Options, 29 | temperatureCreator: TemperatureWithDegreesCreatorType 30 | ) { 31 | self.response = response 32 | self.options = options 33 | self.temperatureCreator = temperatureCreator 34 | } 35 | 36 | func build() -> String? { 37 | if options.unit == .all { 38 | buildTemperatureTextForAllUnits() 39 | } else { 40 | buildTemperatureText(for: options.unit) 41 | } 42 | } 43 | 44 | private func buildTemperatureTextForAllUnits() -> String? { 45 | let temperatureWithDegrees = temperatureCreator.getTemperatureWithDegrees( 46 | temperatureInMultipleUnits: 47 | .init( 48 | fahrenheit: response.temperatureData.tempFahrenheit, 49 | celsius: response.temperatureData.tempCelsius 50 | ), 51 | isRoundingOff: options.isRoundingOff, 52 | isUnitLetterOff: options.isUnitLetterOff, 53 | isUnitSymbolOff: options.isUnitSymbolOff 54 | ) 55 | return temperatureWithDegrees 56 | } 57 | 58 | private func buildTemperatureText(for unit: TemperatureUnit) -> String? { 59 | let temperatureForUnit = unit == .fahrenheit ? 60 | response.temperatureData.tempFahrenheit : 61 | response.temperatureData.tempCelsius 62 | let temperatureWithDegrees = temperatureCreator.getTemperatureWithDegrees( 63 | temperatureForUnit, 64 | unit: unit, 65 | isRoundingOff: options.isRoundingOff, 66 | isUnitLetterOff: options.isUnitLetterOff, 67 | isUnitSymbolOff: options.isUnitSymbolOff 68 | ) 69 | return temperatureWithDegrees 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureWithDegreesCreator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureWithDegreesCreator.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/9/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct TemperatureInMultipleUnits { 12 | let fahrenheit: Double 13 | let celsius: Double 14 | } 15 | 16 | protocol TemperatureWithDegreesCreatorType { 17 | var degreeString: String { get } 18 | 19 | func getTemperatureWithDegrees( 20 | temperatureInMultipleUnits: TemperatureInMultipleUnits, 21 | isRoundingOff: Bool, 22 | isUnitLetterOff: Bool, 23 | isUnitSymbolOff: Bool 24 | ) -> String? 25 | 26 | func getTemperatureWithDegrees( 27 | _ temperature: Double, 28 | unit: TemperatureUnit, 29 | isRoundingOff: Bool, 30 | isUnitLetterOff: Bool, 31 | isUnitSymbolOff: Bool 32 | ) -> String? 33 | } 34 | 35 | final class TemperatureWithDegreesCreator: TemperatureWithDegreesCreatorType { 36 | let degreeString = "\u{00B0}" 37 | 38 | func getTemperatureWithDegrees( 39 | temperatureInMultipleUnits: TemperatureInMultipleUnits, 40 | isRoundingOff: Bool, 41 | isUnitLetterOff: Bool, 42 | isUnitSymbolOff: Bool 43 | ) -> String? { 44 | guard let fahrenheitString = TemperatureFormatter().getFormattedTemperatureString( 45 | temperatureInMultipleUnits.fahrenheit, 46 | isRoundingOff: isRoundingOff 47 | ) else { 48 | return nil 49 | } 50 | guard let celsiusString = TemperatureFormatter().getFormattedTemperatureString( 51 | temperatureInMultipleUnits.celsius, 52 | isRoundingOff: isRoundingOff 53 | ) else { 54 | return nil 55 | } 56 | 57 | let formattedFahrenheit = combineTemperatureWithUnitDegrees( 58 | temperature: fahrenheitString, 59 | unit: .fahrenheit, 60 | isUnitLetterOff: isUnitLetterOff, 61 | isUnitSymbolOff: isUnitSymbolOff 62 | ) 63 | let formattedCelsius = combineTemperatureWithUnitDegrees( 64 | temperature: celsiusString, 65 | unit: .celsius, 66 | isUnitLetterOff: isUnitLetterOff, 67 | isUnitSymbolOff: isUnitSymbolOff 68 | ) 69 | 70 | return [formattedFahrenheit, formattedCelsius] 71 | .joined(separator: " / ") 72 | } 73 | 74 | func getTemperatureWithDegrees( 75 | _ temperature: Double, 76 | unit: TemperatureUnit, 77 | isRoundingOff: Bool, 78 | isUnitLetterOff: Bool, 79 | isUnitSymbolOff: Bool 80 | ) -> String? { 81 | guard let temperatureString = TemperatureFormatter().getFormattedTemperatureString( 82 | temperature, 83 | isRoundingOff: isRoundingOff 84 | ) else { 85 | return nil 86 | } 87 | 88 | return combineTemperatureWithUnitDegrees( 89 | temperature: temperatureString, 90 | unit: unit, 91 | isUnitLetterOff: isUnitLetterOff, 92 | isUnitSymbolOff: isUnitSymbolOff 93 | ) 94 | } 95 | 96 | private func combineTemperatureWithUnitDegrees( 97 | temperature: String, 98 | unit: TemperatureUnit, 99 | isUnitLetterOff: Bool, 100 | isUnitSymbolOff: Bool 101 | ) -> String { 102 | let unitLetter = isUnitLetterOff ? "" : unit.unitString 103 | let unitSymbol = isUnitSymbolOff ? "" : degreeString 104 | return [temperature, unitLetter].joined(separator: unitSymbol) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/UVIndexTextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UVIndexTextBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 10/18/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class UVIndexTextBuilder { 12 | private let initial: String 13 | private let separator: String 14 | 15 | init(initial: String, separator: String) { 16 | self.initial = initial 17 | self.separator = separator 18 | } 19 | 20 | func build(from response: WeatherAPIResponse) -> String { 21 | "\(initial) \(separator) \(constructUVString(from: response))" 22 | } 23 | 24 | func constructUVString(from response: WeatherAPIResponse) -> String { 25 | let currentHour = Calendar.current.component(.hour, from: Date()) 26 | let currentUVIndex = response.getHourlyUVIndex(hour: currentHour) 27 | return "UV Index: \(currentUVIndex)" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/Text/WeatherTextBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherTextBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/15/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import OSLog 10 | 11 | protocol WeatherTextBuilderType { 12 | func build() -> String 13 | } 14 | 15 | final class WeatherTextBuilder: WeatherTextBuilderType { 16 | struct Options { 17 | let isWeatherConditionAsTextEnabled: Bool 18 | let conditionPosition: WeatherConditionPosition 19 | let valueSeparator: String 20 | let temperatureOptions: TemperatureTextBuilder.Options 21 | let isShowingHumidity: Bool 22 | let isShowingUVIndex: Bool 23 | } 24 | 25 | private let response: WeatherAPIResponse 26 | private let options: Options 27 | private let logger: Logger 28 | 29 | init( 30 | response: WeatherAPIResponse, 31 | options: Options, 32 | logger: Logger 33 | ) { 34 | self.response = response 35 | self.options = options 36 | self.logger = logger 37 | } 38 | 39 | func build() -> String { 40 | let finalString = appendTemperatureAsText() |> 41 | appendUVIndex |> 42 | appendHumidityText |> 43 | buildWeatherConditionAsText 44 | return finalString 45 | } 46 | 47 | private func appendTemperatureAsText() -> String { 48 | TemperatureTextBuilder( 49 | response: response, 50 | options: options.temperatureOptions, 51 | temperatureCreator: TemperatureWithDegreesCreator() 52 | ).build() ?? "" 53 | } 54 | 55 | private func appendUVIndex(initial: String) -> String { 56 | guard options.isShowingUVIndex else { return initial } 57 | 58 | return UVIndexTextBuilder( 59 | initial: initial, 60 | separator: options.valueSeparator 61 | ).build(from: response) 62 | } 63 | 64 | private func appendHumidityText(initial: String) -> String { 65 | guard options.isShowingHumidity else { return initial } 66 | 67 | return HumidityTextBuilder( 68 | initial: initial, 69 | valueSeparator: options.valueSeparator, 70 | humidity: response.humidity, 71 | logger: logger 72 | ).build() 73 | } 74 | 75 | private func buildWeatherConditionAsText(initial: String) -> String { 76 | guard options.isWeatherConditionAsTextEnabled else { return initial } 77 | 78 | let weatherCondition = WeatherConditionBuilder(response: response).build() 79 | let weatherConditionText = WeatherConditionTextMapper().map(weatherCondition) 80 | 81 | let combinedString = options.conditionPosition == .beforeTemperature ? 82 | [weatherConditionText, initial] : 83 | [initial, weatherConditionText.lowercased()] 84 | 85 | return combinedString 86 | .compactMap { $0 } 87 | .joined(separator: ", ") 88 | } 89 | } 90 | 91 | precedencegroup ForwardPipe { 92 | associativity: left 93 | } 94 | 95 | infix operator |>: ForwardPipe 96 | 97 | private func |> (value: T, function: (T) -> U) -> U { 98 | function(value) 99 | } 100 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Decorator/WeatherDataBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherDataBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 5/22/21. 6 | // Copyright © 2021 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | protocol WeatherDataBuilderType: AnyObject { 13 | func build() -> WeatherData 14 | } 15 | 16 | final class WeatherDataBuilder: WeatherDataBuilderType { 17 | struct Options { 18 | let unit: MeasurementUnit 19 | let showWeatherIcon: Bool 20 | let textOptions: WeatherTextBuilder.Options 21 | } 22 | 23 | private let response: WeatherAPIResponse 24 | private let options: WeatherDataBuilder.Options 25 | private let logger: Logger 26 | 27 | init( 28 | response: WeatherAPIResponse, 29 | options: WeatherDataBuilder.Options, 30 | logger: Logger 31 | ) { 32 | self.response = response 33 | self.options = options 34 | self.logger = logger 35 | } 36 | 37 | func build() -> WeatherData { 38 | .init( 39 | showWeatherIcon: options.showWeatherIcon, 40 | textualRepresentation: buildTextualRepresentation(), 41 | weatherCondition: buildWeatherCondition(), 42 | response: response 43 | ) 44 | } 45 | 46 | private func buildTextualRepresentation() -> String { 47 | WeatherTextBuilder( 48 | response: response, 49 | options: options.textOptions, 50 | logger: logger 51 | ).build() 52 | } 53 | 54 | private func buildWeatherCondition() -> WeatherCondition { 55 | WeatherConditionBuilder(response: response).build() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Forecaster/WeatherForecaster.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherForecaster.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/11/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Foundation 11 | 12 | protocol WeatherForecasterType { 13 | func seeForecastForCity() 14 | } 15 | 16 | final class WeatherForecaster: WeatherForecasterType { 17 | private let fullWeatherUrl = URL(string: "https://www.weatherapi.com/weather/")! 18 | 19 | func seeForecastForCity() { 20 | NSWorkspace.shared.open(fullWeatherUrl) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Menu Bar/CustomButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomButton.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/21/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CustomButton: View { 12 | let text: LocalizedStringKey 13 | let textColor: Color 14 | let shortcutKey: KeyEquivalent 15 | let onClick: () -> Void 16 | 17 | init(text: LocalizedStringKey, textColor: Color = Color.primary, shortcutKey: KeyEquivalent, onClick: @escaping () -> Void) { 18 | self.text = text 19 | self.textColor = textColor 20 | self.shortcutKey = shortcutKey 21 | self.onClick = onClick 22 | } 23 | 24 | var body: some View { 25 | Button(action: onClick) { 26 | Text(text) 27 | .foregroundStyle(textColor) 28 | .frame(width: 110, height: 22) 29 | }.keyboardShortcut(KeyboardShortcut(shortcutKey)) 30 | } 31 | } 32 | 33 | #Preview { 34 | CustomButton(text: "See Full Weather", shortcutKey: "f") {} 35 | } 36 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Menu Bar/DropdownIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DropdownIcon.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Markus Mayer on 2022-11-21. 6 | // Copyright © 2022 Markus Mayer. 7 | // 8 | 9 | // Icons used by the dropdown menu 10 | enum DropdownIcon { 11 | case location 12 | case thermometer 13 | case sun 14 | case wind 15 | case uvIndexAndAirQualityText 16 | 17 | var symbolName: String { 18 | switch self { 19 | case .location: 20 | "location.north.circle" 21 | case .thermometer: 22 | "thermometer.snowflake.circle" 23 | case .sun: 24 | "sun.horizon.circle" 25 | case .wind: 26 | "wind.circle" 27 | case .uvIndexAndAirQualityText: 28 | "sun.max.circle" 29 | } 30 | } 31 | 32 | var accessibilityLabel: String { 33 | switch self { 34 | case .location: 35 | "Location" 36 | case .thermometer: 37 | "Temperature" 38 | case .sun: 39 | "Sunrise and Sunset" 40 | case .wind: 41 | "Wind data" 42 | case .uvIndexAndAirQualityText: 43 | "UV Index and Air Quality Index" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Menu Bar/MenuOptionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuOptionsView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/21/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MenuOptionData { 12 | let locationText: String 13 | let weatherText: String 14 | let sunriseSunsetText: String 15 | let tempHumidityWindText: String 16 | let uvIndexAndAirQualityText: String 17 | } 18 | 19 | struct MenuOptionsView: View { 20 | let data: MenuOptionData? 21 | let onSeeWeather: () -> Void 22 | let onRefresh: () -> Void 23 | 24 | var body: some View { 25 | VStack(alignment: .leading, spacing: 16) { 26 | VStack(alignment: .leading) { 27 | NonInteractiveMenuOptionView(icon: .location, text: data?.locationText) 28 | NonInteractiveMenuOptionView(icon: .thermometer, text: data?.weatherText) 29 | NonInteractiveMenuOptionView(icon: .sun, text: data?.sunriseSunsetText) 30 | NonInteractiveMenuOptionView(icon: .wind, text: data?.tempHumidityWindText) 31 | NonInteractiveMenuOptionView(icon: .uvIndexAndAirQualityText, text: data?.uvIndexAndAirQualityText) 32 | } 33 | 34 | HStack { 35 | CustomButton( 36 | text: LocalizedStringKey("See Full Weather"), 37 | shortcutKey: "f", 38 | onClick: onSeeWeather 39 | ) 40 | .frame(maxWidth: .infinity, alignment: .leading) 41 | 42 | Spacer() 43 | .frame(maxWidth: .infinity) 44 | 45 | CustomButton(text: LocalizedStringKey("Refresh"), shortcutKey: "r", onClick: onRefresh) 46 | .frame(maxWidth: .infinity, alignment: .trailing) 47 | } 48 | .frame(maxWidth: .infinity) 49 | } 50 | .padding() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Menu Bar/MenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/23/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MenuView: View { 12 | @ObservedObject var viewModel: WeatherViewModel 13 | private var menuOptionData: MenuOptionData? 14 | @State private var configureViewModel: ConfigureViewModel 15 | private var version: String 16 | private var onSeeWeather: () -> Void 17 | private var onRefresh: () -> Void 18 | private var onSave: () -> Void 19 | 20 | init( 21 | viewModel: WeatherViewModel, 22 | onSeeWeather: @escaping () -> Void, 23 | onRefresh: @escaping () -> Void, 24 | onSave: @escaping () -> Void 25 | ) { 26 | self.viewModel = viewModel 27 | self.onSeeWeather = onSeeWeather 28 | self.onRefresh = onRefresh 29 | self.onSave = onSave 30 | 31 | configureViewModel = ConfigureViewModel(configManager: ConfigManager()) 32 | version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "1.0.0" 33 | } 34 | 35 | var body: some View { 36 | VStack { 37 | MenuOptionsView( 38 | data: viewModel.menuOptionData, 39 | onSeeWeather: onSeeWeather, 40 | onRefresh: onRefresh 41 | ) 42 | 43 | Divider() 44 | 45 | ConfigureView( 46 | viewModel: configureViewModel, 47 | version: version, 48 | onSave: { 49 | configureViewModel.saveConfig() 50 | onSave() 51 | }, 52 | onQuit: { 53 | NSApp.terminate(self) 54 | } 55 | ) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Menu Bar/NonInteractiveMenuOptionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NonInteractiveMenuOptionView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/21/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NonInteractiveMenuOptionView: View { 12 | let icon: DropdownIcon 13 | let text: String? 14 | 15 | var body: some View { 16 | HStack(spacing: 6) { 17 | Image(systemName: icon.symbolName) 18 | .renderingMode(.template) 19 | .resizable() 20 | .frame(width: 24, height: 24) 21 | .accessibilityLabel(icon.accessibilityLabel) 22 | 23 | if let text { 24 | Text(text) 25 | .foregroundStyle(Color.secondary) 26 | .frame(maxWidth: .infinity, alignment: .leading) 27 | } 28 | } 29 | } 30 | } 31 | 32 | #Preview { 33 | NonInteractiveMenuOptionView( 34 | icon: .wind, 35 | text: "Test location" 36 | ) 37 | .frame(width: 300, height: 100, alignment: .leading) 38 | .padding() 39 | } 40 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Menu Bar/WindSpeedFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindSpeedFormatter.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 10/12/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct WindSpeedInMultipleUnits { 12 | let meterPerSec: Double 13 | let milesPerHour: Double 14 | } 15 | 16 | protocol WindSpeedFormatterType { 17 | func getFormattedWindSpeedStringForAllUnits( 18 | windData: WindData, 19 | isRoundingOff: Bool 20 | ) -> String 21 | 22 | func getFormattedWindSpeedString( 23 | unit: MeasurementUnit, 24 | windData: WindData 25 | ) -> String 26 | } 27 | 28 | final class WindSpeedFormatter: WindSpeedFormatterType { 29 | private let degreeString = "\u{00B0}" 30 | 31 | private let formatter: NumberFormatter = { 32 | let formatter = NumberFormatter() 33 | formatter.numberStyle = .decimal 34 | formatter.maximumFractionDigits = 1 35 | formatter.roundingMode = .halfUp 36 | return formatter 37 | }() 38 | 39 | func getFormattedWindSpeedStringForAllUnits( 40 | windData: WindData, 41 | isRoundingOff _: Bool 42 | ) -> String { 43 | let mphSpeed = windData.speedMph 44 | let mpsSpeed = mpsSpeedFrom(mphSpeed: mphSpeed) 45 | 46 | let mphRounded = formatter.string(from: NSNumber(value: mphSpeed)) ?? "" 47 | let windSpeedMph = [mphRounded, "mi/hr"].joined() 48 | 49 | let mpsRounded = formatter.string(from: NSNumber(value: mpsSpeed)) ?? "" 50 | let windSpeedMps = [mpsRounded, "m/s"].joined() 51 | 52 | let windSpeedStr = [windSpeedMph, windSpeedMps].joined(separator: " | ") 53 | 54 | return combinedWindString(windData: windData, windSpeed: windSpeedStr) 55 | } 56 | 57 | func getFormattedWindSpeedString( 58 | unit: MeasurementUnit, 59 | windData: WindData 60 | ) -> String { 61 | let mphSpeed = windData.speedMph 62 | let mpsSpeed = mpsSpeedFrom(mphSpeed: mphSpeed) 63 | 64 | let speed = unit == .imperial ? mphSpeed : mpsSpeed 65 | let speedRounded = formatter.string(from: NSNumber(value: speed)) ?? "" 66 | let windSpeedSuffix = unit == .imperial ? "mi/hr" : "m/s" 67 | let windSpeedStr = [speedRounded, windSpeedSuffix].joined() 68 | 69 | return combinedWindString(windData: windData, windSpeed: windSpeedStr) 70 | } 71 | 72 | private func combinedWindString( 73 | windData: WindData, 74 | windSpeed: String 75 | ) -> String { 76 | let windDegreesStr = [String(windData.degrees), degreeString].joined() 77 | let windDirectionStr = "(\(windData.direction))" 78 | let windAndDegreesStr = [windSpeed, windDegreesStr].joined(separator: " - ") 79 | let windFullStr = [windAndDegreesStr, windDirectionStr].joined(separator: " ") 80 | return windFullStr 81 | } 82 | 83 | private func mpsSpeedFrom(mphSpeed: Double) -> Double { 84 | 0.4469 * mphSpeed 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /DatWeatherDoe/UI/Status Bar/StatusBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusBarView.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/23/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StatusBarView: View { 12 | let weatherResult: Result? 13 | 14 | var body: some View { 15 | if let weatherResult { 16 | switch weatherResult { 17 | case let .success(success): 18 | HStack { 19 | if success.showWeatherIcon { 20 | Image(systemName: success.weatherCondition.symbolName) 21 | .renderingMode(.template) 22 | .accessibilityLabel(success.weatherCondition.accessibilityLabel) 23 | } 24 | if let text = success.textualRepresentation { 25 | Text(text) 26 | } 27 | } 28 | case let .failure(failure): 29 | Text(failure.localizedDescription) 30 | } 31 | } 32 | } 33 | } 34 | 35 | #if DEBUG 36 | #Preview { 37 | StatusBarView( 38 | weatherResult: .success( 39 | .init( 40 | showWeatherIcon: true, 41 | textualRepresentation: "88", 42 | weatherCondition: .cloudy, 43 | response: response 44 | ) 45 | ) 46 | ) 47 | .frame(width: 100, height: 50) 48 | } 49 | 50 | #Preview { 51 | StatusBarView(weatherResult: .failure(WeatherError.latLongIncorrect)) 52 | .frame(width: 100, height: 50) 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Parser/CityWeatherResultParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CityWeatherResultParser.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by preckrasno on 14.02.2023. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class CityWeatherResultParser: WeatherResultParser { 12 | override func parse() { 13 | switch weatherDataResult { 14 | case let .success(weatherData): 15 | delegate?.didUpdateWeatherData(weatherData) 16 | case let .failure(error): 17 | guard let weatherError = error as? WeatherError else { return } 18 | 19 | let errorString = weatherError == WeatherError.cityIncorrect ? 20 | errorLabels.cityErrorString : 21 | errorLabels.networkErrorString 22 | 23 | delegate?.didFailToUpdateWeatherData(errorString) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Parser/ZipCodeWeatherResultParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZipCodeWeatherResultParser.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/17/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | final class ZipCodeWeatherResultParser: WeatherResultParser { 10 | override func parse() { 11 | switch weatherDataResult { 12 | case let .success(weatherData): 13 | delegate?.didUpdateWeatherData(weatherData) 14 | case let .failure(error): 15 | guard let weatherError = error as? WeatherError else { return } 16 | 17 | let errorString = weatherError == WeatherError.zipCodeIncorrect ? 18 | errorLabels.zipCodeErrorString : 19 | errorLabels.networkErrorString 20 | 21 | delegate?.didFailToUpdateWeatherData(errorString) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/Coordinates/LocationCoordinatesWeatherRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationCoordinatesWeatherRepository.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/14/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | import OSLog 11 | 12 | final class LocationCoordinatesWeatherRepository: WeatherRepositoryType { 13 | private let appId: String 14 | private let latLong: String 15 | private let networkClient: NetworkClientType 16 | private let logger: Logger 17 | 18 | init( 19 | appId: String, 20 | latLong: String, 21 | networkClient: NetworkClientType, 22 | logger: Logger 23 | ) { 24 | self.appId = appId 25 | self.latLong = latLong 26 | self.networkClient = networkClient 27 | self.logger = logger 28 | } 29 | 30 | func getWeather() async throws -> WeatherAPIResponse { 31 | logger.debug("Getting weather via lat/long") 32 | 33 | do { 34 | let location = try getLocationCoordinatesFrom(latLong) 35 | let url = try WeatherURLBuilder(appId: appId, location: location).build() 36 | let data = try await networkClient.performRequest(url: url) 37 | return try WeatherAPIResponseParser().parse(data) 38 | } catch { 39 | logger.error("Getting weather via lat/long failed") 40 | 41 | throw error 42 | } 43 | } 44 | 45 | private func getLocationCoordinatesFrom(_ latLong: String) throws -> CLLocationCoordinate2D { 46 | try LocationValidator(latLong: latLong).validate() 47 | 48 | let latAndlong = try LocationParser().parseCoordinates(latLong) 49 | return latAndlong 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/Coordinates/LocationParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationParser.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/10/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | 11 | protocol LocationParserType { 12 | func parseCoordinates(_ latLong: String) throws -> CLLocationCoordinate2D 13 | } 14 | 15 | final class LocationParser: LocationParserType { 16 | func parseCoordinates(_ latLong: String) throws -> CLLocationCoordinate2D { 17 | let latLongCombo = latLong.split(separator: ",") 18 | guard latLongCombo.count == 2 else { 19 | throw WeatherError.latLongIncorrect 20 | } 21 | 22 | return try parseLocationDegrees( 23 | possibleLatitude: String(latLongCombo[0]).trim(), 24 | possibleLongitude: String(latLongCombo[1]).trim() 25 | ) 26 | } 27 | 28 | private func parseLocationDegrees( 29 | possibleLatitude: String, 30 | possibleLongitude: String 31 | ) throws -> CLLocationCoordinate2D { 32 | let lat = CLLocationDegrees(possibleLatitude.trim()) 33 | let long = CLLocationDegrees(possibleLongitude.trim()) 34 | guard let lat, let long else { 35 | throw WeatherError.latLongIncorrect 36 | } 37 | 38 | return .init(latitude: lat, longitude: long) 39 | } 40 | } 41 | 42 | private extension String { 43 | func trim() -> String { trimmingCharacters(in: .whitespacesAndNewlines) } 44 | } 45 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/Coordinates/LocationValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationValidator.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/13/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | final class LocationValidator: WeatherValidatorType { 10 | private let latLong: String 11 | 12 | init(latLong: String) { 13 | self.latLong = latLong 14 | } 15 | 16 | func validate() throws { 17 | let coordinates = latLong.split(separator: ",") 18 | let isValid = coordinates.count == 2 19 | if !isValid { 20 | throw WeatherError.latLongIncorrect 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/System/SystemLocationFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemLocationFetcher.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/9/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import CoreLocation 11 | import Foundation 12 | import OSLog 13 | 14 | protocol SystemLocationFetcherType: AnyObject { 15 | func getLocation() async throws -> CLLocationCoordinate2D 16 | } 17 | 18 | final actor SystemLocationFetcher: NSObject, SystemLocationFetcherType { 19 | private let logger: Logger 20 | private var cachedLocation: CLLocationCoordinate2D? 21 | private var permissionContinuation: CheckedContinuation? 22 | private var locationUpdateContinuation: CheckedContinuation? 23 | 24 | @MainActor 25 | private lazy var locationManager: CLLocationManager = { 26 | let locationManager = CLLocationManager() 27 | locationManager.delegate = self 28 | locationManager.desiredAccuracy = kCLLocationAccuracyKilometer 29 | locationManager.distanceFilter = 1000 30 | return locationManager 31 | }() 32 | 33 | init(logger: Logger) { 34 | self.logger = logger 35 | } 36 | 37 | func getLocation() async throws(WeatherError) -> CLLocationCoordinate2D { 38 | guard CLLocationManager.locationServicesEnabled() else { 39 | logger.error("Location services not enabled") 40 | 41 | throw WeatherError.locationError 42 | } 43 | 44 | switch CLLocationManager().authorizationStatus { 45 | case .notDetermined: 46 | let isAuthorized = await withCheckedContinuation { continuation in 47 | Task(priority: .high) { 48 | logger.debug("Location permission not determined") 49 | 50 | permissionContinuation = continuation 51 | 52 | await MainActor.run { 53 | locationManager.requestWhenInUseAuthorization() 54 | } 55 | } 56 | } 57 | 58 | permissionContinuation = nil 59 | 60 | logger.debug("Location permission changed, isAuthorized?: \(isAuthorized)") 61 | 62 | if isAuthorized { 63 | return try await requestLatestOrCachedLocation() 64 | } 65 | 66 | throw WeatherError.locationError 67 | 68 | case .authorized: 69 | return try await requestLatestOrCachedLocation() 70 | 71 | default: 72 | logger.error("Location permission has NOT been granted") 73 | 74 | throw WeatherError.locationError 75 | } 76 | } 77 | 78 | private func updateCachedLocation(_ location: CLLocationCoordinate2D) { 79 | cachedLocation = location 80 | } 81 | 82 | private func requestLatestOrCachedLocation() async throws(WeatherError) -> CLLocationCoordinate2D { 83 | if let cachedLocation = getCachedLocationIfPresent() { 84 | return cachedLocation 85 | } 86 | 87 | do { 88 | let latestLocation = try await withCheckedThrowingContinuation { continuation in 89 | Task(priority: .high) { 90 | locationUpdateContinuation = continuation 91 | 92 | await MainActor.run { 93 | locationManager.startUpdatingLocation() 94 | } 95 | } 96 | } 97 | 98 | locationUpdateContinuation = nil 99 | 100 | return latestLocation 101 | } catch { 102 | throw WeatherError.locationError 103 | } 104 | } 105 | 106 | private func getCachedLocationIfPresent() -> CLLocationCoordinate2D? { 107 | if let cachedLocation { 108 | logger.debug("Sending cached location") 109 | 110 | return cachedLocation 111 | } 112 | 113 | return nil 114 | } 115 | } 116 | 117 | // MARK: CLLocationManagerDelegate 118 | 119 | extension SystemLocationFetcher: CLLocationManagerDelegate { 120 | nonisolated func locationManagerDidChangeAuthorization(_: CLLocationManager) { 121 | let isAuthorized = CLLocationManager().authorizationStatus == .authorized 122 | 123 | Task(priority: .high) { 124 | await permissionContinuation?.resume(returning: isAuthorized) 125 | } 126 | } 127 | 128 | nonisolated func locationManager(_: CLLocationManager, didFailWithError error: Error) { 129 | Task(priority: .high) { 130 | await locationUpdateContinuation?.resume(throwing: error) 131 | } 132 | } 133 | 134 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations _: [CLLocation]) { 135 | let coordinate = manager.location?.coordinate ?? .init(latitude: .zero, longitude: .zero) 136 | Task(priority: .high) { 137 | await locationUpdateContinuation?.resume(returning: coordinate) 138 | await updateCachedLocation(coordinate) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/System/SystemLocationWeatherRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemLocationWeatherRepository.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/15/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | import OSLog 11 | 12 | final class SystemLocationWeatherRepository: WeatherRepositoryType { 13 | private let appId: String 14 | private let location: CLLocationCoordinate2D 15 | private let networkClient: NetworkClientType 16 | private let logger: Logger 17 | 18 | init( 19 | appId: String, 20 | location: CLLocationCoordinate2D, 21 | networkClient: NetworkClientType, 22 | logger: Logger 23 | ) { 24 | self.appId = appId 25 | self.location = location 26 | self.networkClient = networkClient 27 | self.logger = logger 28 | } 29 | 30 | func getWeather() async throws -> WeatherAPIResponse { 31 | logger.debug("Getting weather via location") 32 | 33 | do { 34 | let url = try WeatherURLBuilder(appId: appId, location: location).build() 35 | let data = try await networkClient.performRequest(url: url) 36 | return try WeatherAPIResponseParser().parse(data) 37 | } catch { 38 | logger.error("Getting weather via location failed") 39 | 40 | throw error 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/WeatherRepositoryFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherRepositoryFactory.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 8/7/23. 6 | // Copyright © 2023 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | import Foundation 11 | import OSLog 12 | 13 | protocol WeatherRepositoryFactoryType { 14 | func create(location: CLLocationCoordinate2D) -> WeatherRepositoryType 15 | func create(latLong: String) -> WeatherRepositoryType 16 | } 17 | 18 | final class WeatherRepositoryFactory: WeatherRepositoryFactoryType { 19 | struct Options { 20 | let appId: String 21 | let networkClient: NetworkClient 22 | let logger: Logger 23 | } 24 | 25 | private let appId: String 26 | private let networkClient: NetworkClientType 27 | private let logger: Logger 28 | 29 | init(appId: String, networkClient: NetworkClientType, logger: Logger) { 30 | self.appId = appId 31 | self.networkClient = networkClient 32 | self.logger = logger 33 | } 34 | 35 | func create(location: CLLocationCoordinate2D) -> WeatherRepositoryType { 36 | SystemLocationWeatherRepository( 37 | appId: appId, 38 | location: location, 39 | networkClient: networkClient, 40 | logger: logger 41 | ) 42 | } 43 | 44 | func create(latLong: String) -> WeatherRepositoryType { 45 | LocationCoordinatesWeatherRepository( 46 | appId: appId, 47 | latLong: latLong, 48 | networkClient: networkClient, 49 | logger: logger 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/WeatherRepositoryType.swift: -------------------------------------------------------------------------------- 1 | //// 2 | //// WeatherRepositoryType.swift 3 | //// DatWeatherDoe 4 | //// 5 | //// Created by Inder Dhir on 1/30/16. 6 | //// Copyright © 2016 Inder Dhir. All rights reserved. 7 | //// 8 | 9 | protocol WeatherRepositoryType: AnyObject { 10 | func getWeather() async throws -> WeatherAPIResponse 11 | } 12 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/WeatherURLBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherURLBuilder.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/10/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | import Foundation 11 | 12 | protocol WeatherURLBuilderType { 13 | func build() throws -> URL 14 | } 15 | 16 | final class WeatherURLBuilder: WeatherURLBuilderType { 17 | private let apiUrlString = "https://api.weatherapi.com/v1/forecast.json" 18 | private let appId: String 19 | private let location: CLLocationCoordinate2D 20 | 21 | init(appId: String, location: CLLocationCoordinate2D) { 22 | self.appId = appId 23 | self.location = location 24 | } 25 | 26 | func build() throws -> URL { 27 | let latLonString = "\(location.latitude),\(location.longitude)" 28 | 29 | let queryItems: [URLQueryItem] = [ 30 | URLQueryItem(name: "key", value: appId), 31 | URLQueryItem(name: "aqi", value: String("yes")), 32 | URLQueryItem(name: "q", value: latLonString), 33 | URLQueryItem(name: "dt", value: parsedDateToday) 34 | ] 35 | 36 | var urlComps = URLComponents(string: apiUrlString) 37 | urlComps?.queryItems = queryItems 38 | 39 | guard let finalUrl = urlComps?.url else { 40 | throw WeatherError.unableToConstructUrl 41 | } 42 | return finalUrl 43 | } 44 | 45 | private var parsedDateToday: String { 46 | let dateFormatter = DateFormatter() 47 | dateFormatter.dateFormat = "yyyy-MM-dd" 48 | return dateFormatter.string(from: Date()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/Repository/WeatherValidatorType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherValidatorType.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/10/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | protocol WeatherValidatorType: AnyObject { 10 | func validate() throws 11 | } 12 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/WeatherDataFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherDataFormatter.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 6/24/24. 6 | // Copyright © 2024 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol WeatherDataFormatterType { 12 | func getLocation(for data: WeatherData) -> String 13 | func getWeatherText(for data: WeatherData) -> String 14 | func getSunriseSunset(for data: WeatherData) -> String 15 | func getWindSpeedItem(for data: WeatherData) -> String 16 | func getUVIndexAndAirQuality(for data: WeatherData) -> String 17 | } 18 | 19 | final class WeatherDataFormatter: WeatherDataFormatterType { 20 | let configManager: ConfigManagerType 21 | 22 | init(configManager: ConfigManagerType) { 23 | self.configManager = configManager 24 | } 25 | 26 | func getLocation(for data: WeatherData) -> String { 27 | [ 28 | data.response.locationName, 29 | WeatherConditionTextMapper().map(data.weatherCondition) 30 | ] 31 | .joined(separator: " - ") 32 | } 33 | 34 | func getWeatherText(for data: WeatherData) -> String { 35 | TemperatureForecastTextBuilder( 36 | temperatureData: data.response.temperatureData, 37 | forecastTemperatureData: data.response.forecastDayData.temp, 38 | options: .init( 39 | unit: configManager.parsedMeasurementUnit.temperatureUnit, 40 | isRoundingOff: configManager.isRoundingOffData, 41 | isUnitLetterOff: configManager.isUnitLetterOff, 42 | isUnitSymbolOff: configManager.isUnitSymbolOff 43 | ) 44 | ).build() 45 | } 46 | 47 | func getSunriseSunset(for data: WeatherData) -> String { 48 | SunriseAndSunsetTextBuilder( 49 | sunset: data.response.forecastDayData.astro.sunset, 50 | sunrise: data.response.forecastDayData.astro.sunrise 51 | ).build() 52 | } 53 | 54 | func getWindSpeedItem(for data: WeatherData) -> String { 55 | if configManager.measurementUnit == MeasurementUnit.all.rawValue { 56 | WindSpeedFormatter() 57 | .getFormattedWindSpeedStringForAllUnits( 58 | windData: data.response.windData, 59 | isRoundingOff: configManager.isRoundingOffData 60 | ) 61 | } else { 62 | WindSpeedFormatter() 63 | .getFormattedWindSpeedString( 64 | unit: configManager.parsedMeasurementUnit, 65 | windData: data.response.windData 66 | ) 67 | } 68 | } 69 | 70 | func getUVIndexAndAirQuality(for data: WeatherData) -> String { 71 | let currentHour = Calendar.current.component(.hour, from: Date()) 72 | let currentUVIndex = data.response.getHourlyUVIndex(hour: currentHour) 73 | let uvIndex = "UV Index: \(currentUVIndex)" 74 | 75 | let airQualityIndex = "AQI: \(data.response.airQualityIndex.description)" 76 | 77 | return [uvIndex, airQualityIndex].joined(separator: " | ") 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/WeatherViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherViewModel.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/9/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | @MainActor 13 | final class WeatherViewModel: WeatherViewModelType, ObservableObject { 14 | private let locationFetcher: SystemLocationFetcherType 15 | private var weatherFactory: WeatherRepositoryFactoryType 16 | private let configManager: ConfigManagerType 17 | private var dataFormatter: WeatherDataFormatterType! 18 | private let logger: Logger 19 | private var reachability: NetworkReachability! 20 | 21 | private let forecaster = WeatherForecaster() 22 | private var weatherTimerTask: Task? 23 | 24 | @Published var menuOptionData: MenuOptionData? 25 | @Published var weatherResult: Result? 26 | 27 | init( 28 | locationFetcher: SystemLocationFetcher, 29 | weatherFactory: WeatherRepositoryFactoryType, 30 | configManager: ConfigManagerType, 31 | logger: Logger 32 | ) { 33 | self.locationFetcher = locationFetcher 34 | self.configManager = configManager 35 | self.weatherFactory = weatherFactory 36 | self.logger = logger 37 | 38 | setupReachability() 39 | } 40 | 41 | deinit { 42 | Task { [weatherTimerTask] in 43 | weatherTimerTask?.cancel() 44 | } 45 | } 46 | 47 | func setup(with formatter: WeatherDataFormatter) { 48 | dataFormatter = formatter 49 | } 50 | 51 | func getUpdatedWeatherAfterRefresh() { 52 | weatherTimerTask?.cancel() 53 | weatherTimerTask = Task { [weak self] in 54 | guard let self else { return } 55 | 56 | while !Task.isCancelled { 57 | await self.getWeatherWithSelectedSource() 58 | try? await Task.sleep(for: .seconds(configManager.refreshInterval)) 59 | } 60 | } 61 | } 62 | 63 | func seeForecastForCurrentCity() { 64 | forecaster.seeForecastForCity() 65 | } 66 | 67 | private func setupReachability() { 68 | reachability = NetworkReachability( 69 | logger: logger, 70 | onBecomingReachable: { [weak self] in 71 | self?.getUpdatedWeatherAfterRefresh() 72 | } 73 | ) 74 | } 75 | 76 | private func getWeatherWithSelectedSource() async { 77 | let weatherSource = WeatherSource(rawValue: configManager.weatherSource) ?? .location 78 | 79 | do { 80 | let weatherData = switch weatherSource { 81 | case .location: 82 | try await getWeatherAfterUpdatingLocation() 83 | case .latLong: 84 | try await getWeatherViaLocationCoordinates() 85 | } 86 | updateWeatherData(weatherData) 87 | } catch { 88 | updateWeatherData(error) 89 | } 90 | } 91 | 92 | private func getWeatherAfterUpdatingLocation() async throws -> WeatherData { 93 | let locationFetcher = locationFetcher 94 | let locationTask = Task { 95 | let location = try await locationFetcher.getLocation() 96 | return location 97 | } 98 | 99 | let location = try await locationTask.value 100 | return try await getWeather( 101 | repository: weatherFactory.create(location: location), 102 | unit: configManager.parsedMeasurementUnit 103 | ) 104 | } 105 | 106 | private func getWeatherViaLocationCoordinates() async throws -> WeatherData { 107 | let latLong = configManager.weatherSourceText 108 | guard !latLong.isEmpty else { 109 | throw WeatherError.latLongIncorrect 110 | } 111 | 112 | return try await getWeather( 113 | repository: weatherFactory.create(latLong: latLong), 114 | unit: configManager.parsedMeasurementUnit 115 | ) 116 | } 117 | 118 | private func buildWeatherDataOptions(for unit: MeasurementUnit) -> WeatherDataBuilder.Options { 119 | .init( 120 | unit: unit, 121 | showWeatherIcon: configManager.isShowingWeatherIcon, 122 | textOptions: buildWeatherTextOptions(for: unit) 123 | ) 124 | } 125 | 126 | private func buildWeatherTextOptions(for unit: MeasurementUnit) -> WeatherTextBuilder.Options { 127 | let conditionPosition = WeatherConditionPosition(rawValue: configManager.weatherConditionPosition) 128 | ?? .beforeTemperature 129 | return .init( 130 | isWeatherConditionAsTextEnabled: configManager.isWeatherConditionAsTextEnabled, 131 | conditionPosition: conditionPosition, 132 | valueSeparator: configManager.valueSeparator, 133 | temperatureOptions: .init( 134 | unit: unit.temperatureUnit, 135 | isRoundingOff: configManager.isRoundingOffData, 136 | isUnitLetterOff: configManager.isUnitLetterOff, 137 | isUnitSymbolOff: configManager.isUnitSymbolOff 138 | ), 139 | isShowingHumidity: configManager.isShowingHumidity, 140 | isShowingUVIndex: configManager.isShowingUVIndex 141 | ) 142 | } 143 | 144 | private func getWeather(repository: WeatherRepositoryType, unit: MeasurementUnit) async throws -> WeatherData { 145 | let repository = repository 146 | 147 | let responseTask = Task { 148 | let response = try await repository.getWeather() 149 | return response 150 | } 151 | 152 | do { 153 | let response = try await responseTask.value 154 | let weatherData = WeatherDataBuilder( 155 | response: response, 156 | options: buildWeatherDataOptions(for: unit), 157 | logger: logger 158 | ).build() 159 | return weatherData 160 | } catch { 161 | throw WeatherError.networkError 162 | } 163 | } 164 | 165 | private func updateWeatherData(_ data: WeatherData) { 166 | menuOptionData = MenuOptionData( 167 | locationText: dataFormatter.getLocation(for: data), 168 | weatherText: dataFormatter.getWeatherText(for: data), 169 | sunriseSunsetText: dataFormatter.getSunriseSunset(for: data), 170 | tempHumidityWindText: dataFormatter.getWindSpeedItem(for: data), 171 | uvIndexAndAirQualityText: dataFormatter.getUVIndexAndAirQuality(for: data) 172 | ) 173 | weatherResult = .success(data) 174 | } 175 | 176 | private func updateWeatherData(_ error: Error) { 177 | menuOptionData = nil 178 | weatherResult = .failure(error) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /DatWeatherDoe/ViewModel/WeatherViewModelType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherViewModelType.swift 3 | // DatWeatherDoe 4 | // 5 | // Created by Inder Dhir on 1/9/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | import Combine 10 | 11 | @MainActor 12 | protocol WeatherViewModelType: AnyObject { 13 | func setup(with formatter: WeatherDataFormatter) 14 | func getUpdatedWeatherAfterRefresh() 15 | func seeForecastForCurrentCity() 16 | } 17 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/API/Repository/Location/Coordinates/LocationValidatorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationValidatorTests.swift 3 | // DatWeatherDoeTests 4 | // 5 | // Created by Inder Dhir on 1/26/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | @testable import DatWeatherDoe 10 | import XCTest 11 | 12 | class LocationValidatorTests: XCTestCase { 13 | func testLocation_empty() { 14 | XCTAssertThrowsError(try LocationValidator(latLong: "").validate()) 15 | } 16 | 17 | func testLocation_correct() { 18 | XCTAssertNoThrow(try LocationValidator(latLong: "12,24").validate()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/API/Repository/WeatherURLBuilderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherURLBuilderTests.swift 3 | // DatWeatherDoeTests 4 | // 5 | // Created by Inder Dhir on 1/25/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | @testable import DatWeatherDoe 10 | import XCTest 11 | 12 | final class WeatherURLBuilderTests: XCTestCase { 13 | func testBuild() { 14 | XCTAssertEqual( 15 | try? WeatherURLBuilder(appId: "123456", location: .init(latitude: 42, longitude: 42)).build().absoluteString, 16 | "https://api.weatherapi.com/v1/forecast.json?key=123456&aqi=no&q=42.0,42.0&dt=\(parsedDateToday)" 17 | ) 18 | } 19 | 20 | private var parsedDateToday: String { 21 | let dateFormatter = DateFormatter() 22 | dateFormatter.dateFormat = "yyyy-MM-dd" 23 | return dateFormatter.string(from: Date()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/Config/ConfigManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigManagerTests.swift 3 | // DefaultsTests 4 | // 5 | // Created by Inder Dhir on 5/23/21. 6 | // Copyright © 2021 Inder Dhir. All rights reserved. 7 | // 8 | 9 | @testable import DatWeatherDoe 10 | import XCTest 11 | 12 | final class ConfigManagerTests: XCTestCase { 13 | var configManager: ConfigManagerType! 14 | 15 | override func setUp() { 16 | super.setUp() 17 | clearUserDefaults() 18 | } 19 | 20 | func testDefaultMeasurementUnit() { 21 | XCTAssertEqual(configManager.measurementUnit, MeasurementUnit.imperial.rawValue) 22 | } 23 | 24 | func testMeasurementUnitSaved() { 25 | XCTAssertEqual(configManager.measurementUnit, MeasurementUnit.imperial.rawValue) 26 | configManager.measurementUnit = MeasurementUnit.metric.rawValue 27 | XCTAssertEqual(configManager.measurementUnit, MeasurementUnit.metric.rawValue) 28 | } 29 | 30 | func testDefaultWeatherSource() { 31 | XCTAssertEqual(configManager.weatherSource, WeatherSource.location.rawValue) 32 | } 33 | 34 | func testDefaultWeatherSourceSaved() { 35 | XCTAssertEqual(configManager.weatherSource, WeatherSource.location.rawValue) 36 | configManager.weatherSource = WeatherSource.latLong.rawValue 37 | XCTAssertEqual(configManager.weatherSource, WeatherSource.latLong.rawValue) 38 | } 39 | 40 | func testDefaultWeatherSourceText() { 41 | XCTAssertEqual(configManager.weatherSourceText, nil) 42 | } 43 | 44 | func testWeatherSourceTextSaved() { 45 | XCTAssertEqual(configManager.weatherSourceText, nil) 46 | configManager.weatherSourceText = "40,40" 47 | XCTAssertEqual(configManager.weatherSourceText, "40,40") 48 | } 49 | 50 | func testDefaultRefreshInterval() { 51 | XCTAssertEqual(configManager.refreshInterval, 900) 52 | } 53 | 54 | func testRefreshIntervalSaved() { 55 | XCTAssertEqual(configManager.refreshInterval, 900) 56 | configManager.refreshInterval = 300 57 | XCTAssertEqual(configManager.refreshInterval, 300) 58 | } 59 | 60 | func testDefaultShowingHumidity() { 61 | XCTAssertEqual(configManager.isShowingHumidity, false) 62 | } 63 | 64 | func testShowingHumiditySaved() { 65 | XCTAssertEqual(configManager.isShowingHumidity, false) 66 | configManager.isShowingHumidity = true 67 | XCTAssertEqual(configManager.isShowingHumidity, true) 68 | } 69 | 70 | func testDefaultRoundingOffData() { 71 | XCTAssertEqual(configManager.isRoundingOffData, false) 72 | } 73 | 74 | func testDefaultUnitLetterOffOffData() { 75 | XCTAssertEqual(configManager.isUnitLetterOff, false) 76 | } 77 | 78 | func testDefaultisUnitSymbolOff() { 79 | XCTAssertEqual(configManager.isUnitSymbolOff, false) 80 | } 81 | 82 | func testRoundingOffDataSaved() { 83 | XCTAssertEqual(configManager.isRoundingOffData, false) 84 | configManager.isRoundingOffData = true 85 | XCTAssertEqual(configManager.isRoundingOffData, true) 86 | } 87 | 88 | func testUnitLetterOffSaved() { 89 | XCTAssertEqual(configManager.isUnitLetterOff, false) 90 | configManager.isUnitLetterOff = true 91 | XCTAssertEqual(configManager.isUnitLetterOff, true) 92 | } 93 | 94 | func testUnitSymbolOffSaved() { 95 | XCTAssertEqual(configManager.isUnitSymbolOff, false) 96 | configManager.isUnitSymbolOff = true 97 | XCTAssertEqual(configManager.isUnitSymbolOff, true) 98 | } 99 | 100 | func testWeatherConditionAsTextDefault() { 101 | XCTAssertEqual(configManager.isWeatherConditionAsTextEnabled, false) 102 | } 103 | 104 | func testWeatherConditionAsTextSaved() { 105 | XCTAssertEqual(configManager.isWeatherConditionAsTextEnabled, false) 106 | configManager.isWeatherConditionAsTextEnabled = true 107 | XCTAssertEqual(configManager.isWeatherConditionAsTextEnabled, true) 108 | } 109 | 110 | private func clearUserDefaults() { 111 | let appDomain = Bundle.main.bundleIdentifier ?? "DatWeatherDoe" 112 | UserDefaults.resetStandardUserDefaults() 113 | UserDefaults.standard.removePersistentDomain(forName: appDomain) 114 | configManager = ConfigManager() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/DatWeatherDoe.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "8E1BA5A7-5E49-4CF1-8D13-2F0DA4BF0476", 5 | "name" : "Main Config", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "targetForVariableExpansion" : { 14 | "containerPath" : "container:DatWeatherDoe.xcodeproj", 15 | "identifier" : "2023EDA21C4ED09C0087FD67", 16 | "name" : "DatWeatherDoe" 17 | }, 18 | "testExecutionOrdering" : "random" 19 | }, 20 | "testTargets" : [ 21 | { 22 | "parallelizable" : true, 23 | "target" : { 24 | "containerPath" : "container:DatWeatherDoe.xcodeproj", 25 | "identifier" : "61D04A2E1C7B7BCF00CBE6AE", 26 | "name" : "DatWeatherDoeTests" 27 | } 28 | } 29 | ], 30 | "version" : 1 31 | } 32 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/UI/Configure/Options/RefreshIntervalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshIntervalTests.swift 3 | // DatWeatherDoeTests 4 | // 5 | // Created by Inder Dhir on 2/12/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | @testable import DatWeatherDoe 10 | import XCTest 11 | 12 | final class RefreshIntervalTests: XCTestCase { 13 | func testRefreshIntervalTimes() { 14 | XCTAssertEqual(RefreshInterval.fiveMinutes.rawValue, 300) 15 | XCTAssertEqual(RefreshInterval.fifteenMinutes.rawValue, 900) 16 | XCTAssertEqual(RefreshInterval.thirtyMinutes.rawValue, 1800) 17 | XCTAssertEqual(RefreshInterval.sixtyMinutes.rawValue, 3600) 18 | } 19 | 20 | func testRefreshintervalStrings() { 21 | XCTAssertEqual(RefreshInterval.fiveMinutes.title, "5 min") 22 | XCTAssertEqual(RefreshInterval.fifteenMinutes.title, "15 min") 23 | XCTAssertEqual(RefreshInterval.thirtyMinutes.title, "30 min") 24 | XCTAssertEqual(RefreshInterval.sixtyMinutes.title, "60 min") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/UI/Configure/Options/TemperatureUnitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureUnitTests.swift 3 | // DatWeatherDoeTests 4 | // 5 | // Created by Inder Dhir on 2/12/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | @testable import DatWeatherDoe 10 | import XCTest 11 | 12 | final class TemperatureUnitTests: XCTestCase { 13 | func testFahrenheit() { 14 | let fahrenheitUnit = TemperatureUnit.fahrenheit 15 | 16 | XCTAssertEqual(fahrenheitUnit.unitString, "F") 17 | XCTAssertEqual(fahrenheitUnit.degreesString, "\u{00B0}F") 18 | } 19 | 20 | func testCelsius() { 21 | let fahrenheitUnit = TemperatureUnit.celsius 22 | 23 | XCTAssertEqual(fahrenheitUnit.unitString, "C") 24 | XCTAssertEqual(fahrenheitUnit.degreesString, "\u{00B0}C") 25 | } 26 | 27 | func testAll() { 28 | let fahrenheitUnit = TemperatureUnit.all 29 | 30 | XCTAssertEqual(fahrenheitUnit.unitString, "All") 31 | XCTAssertEqual(fahrenheitUnit.degreesString, "\u{00B0}All") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /DatWeatherDoeTests/UI/Configure/Options/WeatherSourceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherSourceTests.swift 3 | // DatWeatherDoeTests 4 | // 5 | // Created by Inder Dhir on 2/12/22. 6 | // Copyright © 2022 Inder Dhir. All rights reserved. 7 | // 8 | 9 | @testable import DatWeatherDoe 10 | import XCTest 11 | 12 | final class WeatherSourceTests: XCTestCase { 13 | func testLocationSource() { 14 | let locationSource = WeatherSource.location 15 | 16 | XCTAssertEqual(locationSource.title, "Location") 17 | XCTAssertEqual(locationSource.placeholder, "") 18 | XCTAssertEqual(locationSource.textHint, "") 19 | } 20 | 21 | func testLatLongSource() { 22 | let latLongSource = WeatherSource.latLong 23 | 24 | XCTAssertEqual(latLongSource.title, "Lat/Long") 25 | XCTAssertEqual(latLongSource.placeholder, "42,42") 26 | XCTAssertEqual(latLongSource.textHint, "[latitude],[longitude]") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License, 2 | Version 2.0 Apache License Version 2.0, 3 | January 2004 http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. 30 | 31 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 32 | 33 | 3. Grant of Patent License. 34 | 35 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 36 | 37 | 4. Redistribution. 38 | 39 | You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 40 | 41 | You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 42 | 43 | 5. Submission of Contributions. 44 | 45 | Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 46 | 47 | 6. Trademarks. 48 | 49 | This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 50 | 51 | 7. Disclaimer of Warranty. 52 | 53 | Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 54 | 55 | 8. Limitation of Liability. 56 | 57 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 58 | 59 | 9. Accepting Warranty or Additional Liability. 60 | 61 | While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 62 | 63 | END OF TERMS AND CONDITIONS 64 | 65 | APPENDIX: How to apply the Apache License to your work 66 | 67 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. 68 | 69 | Copyright 2016 Inder Dhir 70 | 71 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 72 | 73 | http://www.apache.org/licenses/LICENSE-2.0 74 | 75 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [](image.png) DatWeatherDoe 2 | 3 | > **Note** 4 | OpenWeatherMap API 2.5 support is ending in June 2024. The app uses WeatherAPI going forward with location support. 5 | 6 | - Fetch weather using: 7 | - Location services 8 | - Latitude / Longitude 9 | - Configurable polling interval 10 | - Dark mode support 11 | - Supports MacOS 13.0+ 12 | 13 | ## Screenshots 14 | 15 | ![Screenshot 1](screenshot_1.png)\ 16 | ![Screenshot 2](screenshot_2.png) 17 | 18 | ## Installation 19 | 20 | ### Homebrew Cask 21 | 22 | `brew install --cask datweatherdoe` 23 | 24 | ### Manual 25 | 26 | 27 | 28 | ## Using Location Services 29 | 30 | If using location, please make sure that the app has permission to access location services on macOS. 31 | 32 | `System Preferences > Security & Privacy > Privacy > Location Services` 33 | 34 | ![Location services screenshot 1](location_services_1.png) 35 | ![Location services screenshot 2](location_services_2.png) 36 | 37 | ## Developer Setup 38 | 39 | - Get your personal API key for WeatherAPI [here](https://www.weatherapi.com) 40 | - Add the following in "Config.xcconfig": 41 | 42 | ```env 43 | WEATHER_API_KEY=YOUR_KEY 44 | ``` 45 | 46 | ## Donate 47 | 48 | Buy me a coffee to support the development of this project. 49 | 50 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y211O253) 51 | 52 | ## Contributing 53 | 54 | Please see CONTRIBUTING.md 55 | -------------------------------------------------------------------------------- /location_services_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/location_services_1.png -------------------------------------------------------------------------------- /location_services_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/location_services_2.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/logo.png -------------------------------------------------------------------------------- /screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/screenshot_1.png -------------------------------------------------------------------------------- /screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/screenshot_2.png --------------------------------------------------------------------------------